diff --git a/.ctags b/.ctags new file mode 100644 index 000000000..d3980c117 --- /dev/null +++ b/.ctags @@ -0,0 +1,3 @@ +--python-kinds +--languages=python +-R diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..104681e3c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +**/__pycache__ +*.pyc +*.pyd +*.pyo +.Python +.cache +.coverage +.ctags +.dockerignore +.env +.git +.gitignore +.pytest_cache +.tox +.travis.yml +.tx +.venv +AUTHORS.rst +CHANGELOG.md +CODE_OF_CONDUCT.md +CONTRIBUTING.md +DCOLICENSE +docker/Dockerfile +ISSUE_TEMPLATE.md +README.md +__pycache__ +env +local.py +locale +media +pytest.ini +regenerate.sh +requirements-devel.in +requirements-devel.txt +requirements.in +requirements-contribs.in +scripts +setup.cfg +static +tests +thankyou.md +tox.ini +venv +.direnv +.envrc diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..3981ee92b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,47 @@ +--- +name: "\U0001F41E Bug report" +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' + +--- + + + +**Describe the bug** + + +**How can we reproduce the behavior** + + +**Workarounds** + + +**Screenshots** + + +**Taiga environment** + + +**Desktop (please complete the following information):** + - **OS:** + - **Browser:** + - **Version:** + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..51fd870d4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,34 @@ +--- +name: "\U0001F680 Feature request" +about: Suggest an idea for this project +title: "[FR] " +labels: '' +assignees: '' + +--- + +> **READ THIS FIRST!**: We recently announced Taiga plans for the future and they greatly affect how we manage this repository and the current Taiga 6 release. Check it [here](https://blog.taiga.io/announcing_taiganext.html). + + + +**Please describe the problem / need you are trying to solve.** + + +**Describe the feature or the improvement you'd like and what are you trying to achieve.** + + +**Describe alternatives you've considered** + + +**Additional context** + diff --git a/.github/workflows/tests-and-coverall.yml b/.github/workflows/tests-and-coverall.yml new file mode 100644 index 000000000..bc7822d8c --- /dev/null +++ b/.github/workflows/tests-and-coverall.yml @@ -0,0 +1,69 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Taiga Back - Test and Coverage + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [ '3.8', '3.9', '3.10', '3.11' ] + + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: taiga + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + options: '--health-cmd "pg_isready" --health-interval 5s --health-timeout 5s --health-retries 6 --health-start-period 20s' + rabbitmq: + image: rabbitmq + ports: + - 5672:5672 + # needed because the rabbitmq container does not provide a healthcheck + options: '--health-cmd "rabbitmqctl status" --health-interval 5s --health-timeout 5s --health-retries 6 --health-start-period 20s' + + name: Test on Python ${{ matrix.python-version }} + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Enable cache + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip wheel + pip install -r requirements.txt -r requirements-devel.txt + if: steps.cache.outputs.cache-hit != 'true' + + - name: Test with pytest and calculate coverage + run: | + coverage run --source=taiga --omit='*tests*,*commands*,*migrations*,*admin*,*.jinja,*dashboard*,*settings*,*wsgi*,*questions*,*documents*' -m pytest -v --tb=native --pythonwarnings=default + + - name: Publish in Coveralls + uses: AndreMiras/coveralls-python-action@develop + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: python-${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore index 93f5b982c..d79ae8389 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,29 @@ +<<<<<<< HEAD db-data static-data +======= +.*.sw* +.#* +*.log +taiga/search +settings/local.py +settings/celery_local.py +database.sqlite +logs +media +static +!taiga/base/static +*.pyc +*.mo +.venv +.coverage +.cache +.\#* +.project +.idea +.env +.envrc +.direnv +settings/config.py +celerybeat-schedule +>>>>>>> upstream/stable diff --git a/.tx/config b/.tx/config new file mode 100644 index 000000000..eabe8fee3 --- /dev/null +++ b/.tx/config @@ -0,0 +1,9 @@ +[main] +host = https://www.transifex.com +lang_map = sr@latin:sr_Latn, zh_CN:zh_Hans, zh_TW:zh_Hant, fa_IR:fa + +[taiga-back.taiga] +file_filter = taiga/locale//LC_MESSAGES/django.po +source_file = taiga/locale/en/LC_MESSAGES/django.po +source_lang = en +type = PO diff --git a/AUTHORS.rst b/AUTHORS.rst index 3ac7b3c20..faea82ce0 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -25,3 +25,30 @@ Other previous core team members: - Jesus Espino Garcia Special thanks to `Kaleidos Open Source S.L. `_ for providing time for Taiga development. +<<<<<<< HEAD +======= + +And here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS, people who have submitted patches, reported bugs, added translations, helped answer newbie questions, and generally made Taiga that much better: + +- Alejandro Gómez +- Allister Antosik +- Alonso Torres +- Andrea Stagi +- Andrés Moya +- Andrey Alekseenko +- Brett Profitt +- Bruno Clermont +- Chris Wilson +- David Burke +- Everardo Medina +- Hector Colina +- Joe Letts +- Julien Palard +- luyikei +- Michael Jurke +- Motius GmbH +- Riccardo Coccioli +- Ricky Posner +- Stefan Auditor +- Yaser Alraddadi +>>>>>>> upstream/stable diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a4d4ee7b..25b3b5f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +<<<<<<< HEAD ## 6.8.1 (Unreleased) - ... @@ -30,10 +31,109 @@ ## 6.5.0 (2022-01-24) - Compatible with Taiga 6.5.0 +======= +## 6.8.3 (unreleased) + +- ... + +## 6.8.2 (2025-01-13) + +- Fix: Error on load_dump when JSON is a list. + +## 6.8.1 (2024-07-23) + +- Changes to queries to improve their performance. +- Fix: webhooks error. +- Fix: multiple object returned on neighbour. +- Update locales. + +## 6.8.0 (2024-04-03) + +- Changed the namespace of the repositories, from kaleidos-ventures to taigaio +- Fix SECURE_PROXY_SSL_HEADER, adjust header name to match standard (thanks [@ChriFo](https://github.com/ChriFo)) +- Fix error appliying migrations: "resource module not found" (thanks [@VirajAdiga](https://github.com/VirajAdiga)) +- Allow to disable the use of server-side cursors to support pgbouncer (thanks [@ordinary-dev](https://github.com/ordinary-dev)) +- Inmproved RabbitMQ host configuration for Taiga events and asynchrous tasks (thanks [@iriseden](https://github.com/iriseden)) +- Make options for webhooks configurable via env vars in docker (thanks [@slakner](https://github.com/slakner)) + +## 6.7.3 (2024-02-21) + +- GitHub Importer: fix import error with issues associated to a closed milestone. +- Trello Importer: fix import error with attachemts without owner. +- Trello Importer: fix import error when attachemt name ends with '/'. +- User Story Report: add epic refs. + +## 6.7.2 (2024-02-16) + +- Docker: DEBUG and LANGUAGE_CODE settings are customizable. + +## 6.7.1 (2023-09-20) + +- Upgrade gitlab auth contrib plugin +- Set celery timezone customizable from docker envs (thanks to [@wowi42](https://github.com/wowi42)) +- Fix some typos in comments syntax in settings files (thanks to [@Ser5](https://github.com/Ser5)) + +## 6.7.0 (2023-06-12) + +- Security improvements to webhooks functionality. +- Make migrations compatible with Postgresql-14 + +## 6.6.2 (2023-03-24) + +- Fix queries related to telemetry service. + +## 6.6.1 (2023-03-13) + +- Fix error generating the front sitemaps.xml files + +## 6.6.0 (2023-03-06) + +- Add support for Python 3.11 +- Remove support for Python 3.7 +- Upgrade to Django 3.2 (and upgrade some other dependencies) +- Fix some silent css error in email templates (thanks to [@sarbanha](https://github.com/sarbanha)) +- Improves the performance of operations on tags (thanks to [@theriverman](https://github.com/theriverman)) +- Remove `write` permission from Trello importer (thanks to [@northben](https://github.com/northben)) +- DOCKER: Use python-3.11-slim as base image +- DOCKER: Adapt Dockerfile to the removal of psycopg2-binary +- DOCKER: Added env `POSTGRES_SSLMODE` with default value disabled (thanks to [@ribeiromiranda](https://github.com/ribeiromiranda)) + +## 6.5.2 (2022-09-26) + +- Updated links to the Taiga community site. +- Update locales. +- Drop psycopg2-binary dependency; use psycopg2 instead + +## 6.5.1 (2022-01-27) + +- The maximum number of pending invitations is per project again. +- Add more stats in the User detail section in the admin panel. + +## 6.5.0 (2022-01-24) + +- Now member limits by projects are counted by owner and not by project +- Fix Trello importer, now it does not generate empty attachments (issue #tg-4782) +- Fix previsualization of .psd files for attachments, project logos and user avatars +- Upgrade project dependencies. Now taiga only works with python >= 3.7 + +## 6.4.3 (2021-10-27) + +- Update locales + +## 6.4.2 (2021-09-16) + +- Update locales + +## 6.4.1 (2021-09-08) + +- fix(settings): increase default lifetime of ACCESS and REFRESH token +- fix(userstories): invalid serializer for userstories if only_ref=true +>>>>>>> upstream/stable ## 6.4.0 (2021-09-06) - Serve Taiga in subpath +<<<<<<< HEAD ## 6.3.0 (2021-08-10) @@ -86,3 +186,902 @@ Adatp to latest - main `docker-compose.yml` for Taiga installation - `docker-compose-inits.yml` to run `python manage.py` with docker-compose +======= +- Add new serializer to userstories endpoint witn minimal info (id, ref) (issue #tg-4739) + +## 6.3.0 (2021-08-10) + +- New Auth module, based on djangorestframework-simplejwt (history #tg-4625, issue #tgg-626)) +- Fix the user when closing an issue from Github (issue #tg-4563) + +## 6.2.2 (2021-07-15) + +- Add date_cancelled to User +- Avoid logging in the history changes in the attachment's urls or its order (issue #tg-4247) +- Username and email are case insensitive for new registrations. + +## 6.2.1 (2021-06-22) + +- Add new bulk_order_update endpoint to reorder attachments in bulk (issue #tgg-218) +- [Performance] User stories endpoint is too slow for dashboard queries (issue #tg-4600) + +## 6.2.0 (2021-06-09) + +- Change api response from 404 to 401 when not logged in (issues #tg-4415, #tg-4301) +- Allows to order issues by 'ref' field (issue #tg-4503) +- Generate history entries, timeline entries and webhook requests after kanban order is updated (issues #tg-4311, #tg-4340) +- Fix showing epic-related private uss on a public timeline (issue #tg-4291) +- Fix filter userstories by assignation for registered no-member users (issue #tg-2533) +- New algorithm to reorder user stories in the backlog (issue #tg-62) +- Fix wrong behaviour, deleted (inactive) users can still perform API calls (issue #tg-732) + +## 6.1.1 (2021-05-18) + +- Fix user avatar in Trello importer + +## 6.1.0 (2021-05-04) + +- Render markdown to html for checkbox +- Update github templates + +## 6.0.9 (2021-04-13) + +- Migrated to weblate and new translations +- Update dependencies + +## 6.0.8 (2021-04-13) + +- Improve docker configuration +- fix(userstories): close or open userstories afte they are moved in bulk in kanban + +## 6.0.7 (2021-03-09) + +- fix(email): catch smtp errors to prevent app crashes + +## 6.0.6 (2021-03-01) + +- Fix api message + +## 6.0.5 (2021-02-22) + +- Added translation to Dansk +- Added translation to Serbian +- Added translation to Vietnamese +- Simplify and improve docker configuration +- Improve Github integration (edit, close and reopen issue events) +- Fix Asana importer + +## 6.0.4 (2021-02-15) + +### Misc + +- Fix importer: ignore epic related user stories from another project +- Change default sitemap page size +- Improve docker configuration + +## 6.0.3 (2020-02-08) + +### Misc + +- Fix: ENABLE_WEBHOOKS for docker images + +## 6.0.2 (2020-02-08) + +### Features + +- Update colors for default project template fixtures. + +### Misc + +- Minor fixes related to importers and integrations. + +## 6.0.1 (2020-02-02) + +### Misc + +- Fix 'create user' form in django admin panel. + +## 6.0.0 (2020-02-02) + +### Features + +- Swimlanes +- Generate docker image +- Revamp email design +- Improve Gitlab integration (edit, close and reopen issue events) + +### Misc + +- Changed the configuration style to expect DJANGO_SETTINGS_MODULE +- Improved performance when reordering +- Updated dependencies + +## 5.5.9 (2020-12-21) + +### Fix + +- Fix attachment refresh feature. +- Fix welcome email template layout. + +### Misc + +- Updated requirements. **Please note that Python 3.5 is not supported**. +- Several minor changes. + +### i18n + +- Add Arabic. +- Update Russian. + +## 5.5.7 (2020-11-11) + +### Misc + +- Upgrade requirements. +- Fix deprecation warnings. + +## 5.5.5 (2020-09-16) + +### Misc + +- Improve verify email feature for invited users. + +### i18n + +- Update catalog. +- Update fa. + +## 5.5.4 (2020-09-08) + +### Misc + +- Upgrade requirements. + +### i18n + +- Update French translation. + +## 5.5.3 (2020-09-02) + +### Misc + +- Parametrize mdrender cache options. +- Minor bug fix. + +## 5.5.2 (2020-08-26) + +### Misc + +- Tweaks mdrender cache. + +## 5.5.1 (2020-08-23) + +### Features + +- Prevent member creation to users with unverified email address. + +## 5.5.0 (2020-08-19) + +### Features + +- Verify user email. +- Task promotion creates user story and deletes original task. + +### Misc + +- Upgraded Django version to 2.2. This is a BREAKING CHANGE. Contributed + modules should be upgraded. +- Several minor bugfixes. + +## 5.0.15 (2020-06-17) + +### Misc + +- Fixed bug old dump format project import. + +## 5.0.14 (2020-06-16) + +### Misc + +- Fixed several minor bugs. + +## 5.0.13 (2020-06-08) + +### Features + +- Resolved Django deprecation warnings to prepare for an upgrade. +- Added option to disallow anonymous access to user profiles. + +### Misc + +- Updated requirements. +- Use pip-tools to manage dependencies. + +### i18n + +- Updated translations (lv). + +## 5.0.12 (2020-05-12) + +### Security + +- Avoid change in membership attribute. We encourage all users of Taiga + to upgrade as soon as possible. + +## 5.0.11 (2020-05-04) + +### Misc + +- Fixed several minor bugs. +- Updated requirements. + +### i18n + +- Updated translations (es, lv, ru, tr, uk). + +## 5.0.9 (2020-03-11) + +### Feature + +- Implemented new simplified email messages. + +### Misc + +- Fixed several minor bugs. +- Updated requirements. + +### i18n + +- Updated lots of strings and updated their translations. + +## 5.0.8 (2020-02-17) + +### i18n + +- Update Basque translation and others. + +### Misc + +- Several minor bugfixes. + +## 5.0.7 (2020-02-06) + +### Feature + +- Add reduce notifications configuration option. +- Sanitize full name input. + +### i18n + +- Add Latvian translation. + +### Misc + +- Several minor bugfixes. + +## 5.0.6 (2020-01-15) + +### Misc + +- Minor fix on contact project team feature. + +## 5.0.5 (2020-01-08) + +### Feature + +- Promote task and issues to user story with watchers, attachments and comments. + +### Misc + +- Several minor bugfixes and translation updates. + +## 5.0.0 (2019-11-13) + +- Refresh attachment URL on markdown fields to support protected backend. +- Update requirements. +- Update translations: Persian (Iran), French, Portuguese (Brazil). + +## 4.2.14 (2019-10-01) + +- Update requirements to support python3.7. This is a potentially BREAKING + CHANGE. Several libraries were updated to minor and patch releases. + Contributed modules should be tested thoroughly. +- Minor bug fixes. + +## 4.2.12 (2019-08-06) + +### Misc + +- Upgrade requirements +- Events refactoring + +## 4.2.11 (2019-07-24) + +### Misc + +- Asana bug fix. + +## 4.2.10 (2019-07-11) + +### Misc + +- Remove role points project signal from patch US's. +- Improve US's statuses filter by project. + + +## 4.2.7 (2019-06-24) + +### Misc + +- Add default settings slug configuration. +- Minor bug fixes. + +## 4.2.6 (2019-06-12) + +### Misc + +- Recreate timeline indexes. +- Minor bug fixes. + + +## 4.2.4 / 4.2.5 (2019-05-09) + +### Misc + +- Fix epics excluded filter (https://tree.taiga.io/project/taiga/issue/5727) +- Avoid saving non integer user id's in history diffs +- Upgrade requests dependence + + +## 4.2.3 (2019-04-16) + +### Features: + +- Change tag filter behavour to 'or' operator + +### Misc + +- Change milestone query +- Avoid getting non image thumbnails +- Remove unnecesary queries on saving items +- Update messages catalog +- Minor bug fixes. + +## 4.2.2 (2019-03-21) + +### Features: + +- Fix milestone US serializer + +## 4.2.1 (2019-03-20) + +### Features: + +- Add dashboard filter for user stories updating queryset and serializer +- Change milestone user story serializer +- Remove additional order by in timeline queryset +- Add user project slight queryset and serializer +- Filter history comments queryset + +### Misc + +- Minor bug fixes. + +## 4.2.0 (2019-02-28) + +### Features: + +- Promote Tasks to US +- Improve queries +- Activate Hebrew and Basque languages + +### Misc + +- Minor bug fixes. + +## 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 + +- Prevent local Webhooks + +## 3.4.4 (2018-09-19) + +### Misc + +- Small fixes + +## 3.4.3 (2018-09-19) + +### Misc + +- Refactor attachment url's in timeline +- Avoid receive feedkback in private projects from non-members +- Allow delete reports uuid's +- Small fixes + +## 3.4.0 Pinus contorta (2018-08-13) + +### Features + +- Due dates configuration (https://tree.taiga.io/project/taiga/issue/3070): + - Add due dates to admin attributes + - Update project templates +- Issues to Sprints (https://tree.taiga.io/project/taiga/issue/1181): + - Add milestone filters + +## 3.3.14 (2018-08-06) + +### Misc + +- Improve US reorder algorithm. +- Drop python 3.4 and add python 3.6 to travis configuration. + +## 3.3.13 (2018-07-05) + +### Misc + +- Minor bug fixes. + +## 3.3.11 (2018-06-27) + +### Features + +- Add assigned users kanban/taskboard filter. +- Improve US reorder in kanban. +- Upgrade psycopg2 library. + +## 3.3.8 (2018-06-14) + +### Misc + +- Minor bug fix. + +## 3.3.7 (2018-05-31) + +### Misc + +- Minor bug fix related with project import. +- Pin requirements to solve incompatible versions detected by pip 10. + +## 3.3.4 (2018-05-24) + +### Misc + +- Add features to fulfill GDPR. + +## 3.3.3 (2018-05-10) + +### Misc + +- Update locales. +- Minor bug fixes. + +## 3.3.1 (2018-04-30) + +### Misc + +- Minor bug fixes. + +## 3.3.1 (2018-04-30) + +### Misc + +- Minor bug fixes. + +## 3.3.0 Picea mariana (2018-04-26) + +### Features + +- Add "live notifications" to Taiga: + - Migration for user configuration. +- Add due date to US, tasks and issues (https://tree.taiga.io/project/taiga/issue/3070): + - Add to csv export. + - Add to projects import/export. + - Add to webhooks. + - Add to django admin. +- Add multiple assignement only in US (https://tree.taiga.io/project/taiga/issue/1961): + - The `assigned_to` field is still active. + - Add to csv export. + - Add to projects import/export. + - Add to webhooks. + - Add to django admin. +- Delete cards in Kanban and sprint Taskboard (https://tree.taiga.io/project/taiga/issue/2683). + +## 3.2.3 (2018-04-04) + +### Misc + +- Fix milestone burndown graph with empty US. +- Upgrade markdown library to solve bug. +- Update locales. + +## 3.2.2 (2018-03-15) + +### Misc + +- Minor bug fixes. + + +## 3.2.0 Betula nana (2018-03-07) + +### Features +- Add role filtering in US. + + +## 3.1.3 (2018-02-28) + +### Features +- Increase token entropy. +- Squash field changes on notification emails +- Minor bug fixes. + + +## 3.1.0 Perovskia Atriplicifolia (2017-03-10) + +### Features +- Contact with the project: if the projects have this module enabled Taiga users can contact them. +- Ability to create rich text custom fields in Epics, User Stories, Tasks and Isues. +- Full text search now use simple as tokenizer so search with non-english text are allowed. +- Duplicate project: allows creating a new project based on the structure of another (status, tags, colors, default values...) +- Add thumbnails and preview for PSD files. +- Add thumbnails and preview for SVG files (Cario lib is needed). +- i18n: + - Add japanese (ja) translation. + - Add korean (ko) translation. + - Add chinese simplified (zh-Hans) translation. +- Third party services project importers: + - Trello + - Jira 7 + - Github + - Asana + +### Misc +- API: + - Memberships API endpoints now allows using usernames and emails instead of using only emails. + - Contacts API allow full text search (by the username, full name or email). + - Filter milestones, user stories and tasks by estimated_start and estimated_finish dates. + - Add project_extra_info to epics, tasks, milestones, issues and wiki pages endpoints. +- Gogs integration: Adding new Gogs signature method. +- Lots of small and not so small bugfixes. + + +## 3.0.0 Stellaria Borealis (2016-10-02) + +### Features +- Add Epics. +- Include created, modified and finished dates for tasks in CSV reports. +- Add gravatar url to Users API endpoint. +- ProjectTemplates now are sorted by the attribute 'order'. +- Create enpty wiki pages (if not exist) when a new link is created. +- Diff messages in history entries now show only the relevant changes (with some context). +- User stories and tasks listing API call support extra params to include more data (tasks and attachemnts and attachments, respectively) +- Comments: + - Now comment owners and project admins can edit existing comments with the history Entry endpoint. + - Add a new permissions to allow add comments instead of use the existent modify permission for this purpose. +- Tags: + - New API endpoints over projects to create, rename, edit, delete and mix tags. + - Tag color assignation is not automatic. + - Select a color (or not) to a tag when add it to stories, issues and tasks. +- Improve search system over stories, tasks and issues: + - Search into tags too. (thanks to [Riccardo Cocciol](https://github.com/volans-)) + - Weights are applied: (subject = ref > tags > description). +- Import/Export: + - Gzip export/import support. + - Export performance improvements. +- Add filter by email domain registration and invitation by setting. +- Third party integrations: + - Included gogs as builtin integration. + - Improve messages generated on webhooks input. + - Add mentions support in commit messages. + - Cleanup hooks code. + - Rework webhook signature header to align with larger implementations and defined [standards](https://superfeedr-misc.s3.amazonaws.com/pubsubhubbub-core-0.4.html\#authednotify). (thanks to [Stefan Auditor](https://github.com/sanduhrs)) +- Add created-, modified-, finished- and finish_date queryset filters + - Support exact match, gt, gte, lt, lte + - added issues, tasks and userstories accordingly +- i18n: + - Add norwegian Bokmal (nb) translation. + +### Misc +- [API] Improve performance of some calls over list. +- Lots of small and not so small bugfixes. + + +## 2.1.0 Ursus Americanus (2016-05-03) + +### Features +- Add sprint name and slug on search results for user stories (thanks to [@everblut](https://github.com/everblut)) +- [API] projects resource: Random order if `discover_mode=true` and `is_featured=true`. +- Webhooks: Improve webhook data: + - add permalinks + - owner, assigned_to, status, type, priority, severity, user_story, milestone, project are objects + - add role to 'points' object + - add the owner to every notification ('by' field) + - add the date of the notification ('date' field) + - show human diffs in 'changes' + - remove unnecessary data +- CSV Reports: + - Change field name: 'milestone' to 'sprint' + - Add new fields: 'sprint_estimated_start' and 'sprint_estimated_end' +- Importer: + - Remove project after load a dump file fails + - Add more info the the logger if load a dump file fails + +### Misc +- Lots of small and not so small bugfixes. + + +## 2.0.0 Pulsatilla Patens (2016-04-04) + +### Features +- Ability to create url custom fields. (thanks to [@astagi](https://github.com/astagi)). +- Blocked projects support +- Transfer projects ownership support +- Customizable max private and public projects per user +- Customizable max of memberships per owned private and public projects + +### Misc +- Lots of small and not so small bugfixes. + + +## 1.10.0 Dryas Octopetala (2016-01-30) + +### Features +- Add logo field to project model +- Add is_featured field to project model +- Add is_looking_for_people and looking_for_people_note fields to project model +- Filter projects list by + - is_looking_for_people + - is_featured + - is_backlog_activated + - is_kanban_activated +- Search projects by text query (order by ranking name > tags > description) +- Order projects list: + - alphabetically by default + - by fans (last week/moth/year/all time) + - by activity (last week/moth/year/all time) +- Show stats for discover secction +- i18n. + - Add swedish (sv) translation. + - Add turkish (tr) translation. + +### Misc +- Lots of small and not so small bugfixes. + + +## 1.9.1 Taiga Tribe (2016-01-05) + +### Features +- [CSV Reports] Add fields "created_date", "modified_date", "finished_date" to issues CSV report. +- [Attachment] Generate 'card-image' size (300x200) thumbnails for attached image files. + +### Misc +- Improve login and forgot password: allow username or email case-insensitive if the query only + match with one user. +- Improve the django admin panel, now it is more usable and all the selector fields works properly. +- [API] Add tribe_gig field to user stories (improve integration between Taiga and Taiga Tribe). +- [API] Performance improvements for project stats. +- [Events] Add command to send an instant notifications to all the currently online users. +- Lots of small and not so small bugfixes. + + +## 1.9.0 Abies Siberica (2015-11-02) + +### Features +- Project can be starred or unstarred and the fans list can be obtained. +- US, tasks and Issues can be upvoted or downvoted and the voters list can be obtained. +- Now users can watch public issues, tasks and user stories. +- Add endpoints to show the watchers list for issues, tasks and user stories. +- Add a "field type" property for custom fields: 'text', 'multiline text' and 'date' right nowi + (thanks to [@artlepool](https://github.com/artlepool)). +- Allow multiple actions in the commit messages. +- Now every user that coments USs, Issues or Tasks will be involved in it (add author to the watchers list). +- Now profile timelines only show content about the objects (US/Tasks/Issues/Wiki pages) you are involved. +- Add custom videoconference system. +- Fix the compatibility with BitBucket webhooks and add issues and issues comments integration. +- Add support for comments in the Gitlab webhooks integration. +- Add externall apps: now Taiga can integrate with hundreds of applications and service. +- Improve searching system, now full text searchs are supported +- Add sha1 hash to attachments to verify the integrity of files (thanks to [@astagi](https://github.com/astagi)). +- i18n. + - Add italian (it) translation. + - Add polish (pl) translation. + - Add portuguese (Brazil) (pt_BR) translation. + - Add russian (ru) translation. + +### Misc +- Made compatible with python 3.5. +- Migrated to django 1.8. +- Update the rest of requirements to the last version. +- Improve export system, now is more efficient and prevents possible crashes with heavy projects. +- API: Mixin fields 'users', 'members' and 'memberships' in ProjectDetailSerializer. +- API: Add stats/system resource with global server stats (total project, total users....) +- API: Improve and fix some errors in issues/filters_data and userstories/filters_data. +- API: resolver suport ref GET param and return a story, task or issue. +- Webhooks: Add deleted datetime to webhooks responses when isues, tasks or USs are deleted. +- Add headers to allow threading for notification emails about changes to issues, tasks, user stories, + and wiki pages. (thanks to [@brett](https://github.com/brettp)). +- Lots of small and not so small bugfixes. + + +## 1.8.0 Saracenia Purpurea (2015-06-18) + +### Features +- Improve timeline resource. +- Add sitemap of taiga-front (the web client). +- Search by reference (thanks to [@artlepool](https://github.com/artlepool)) +- Add call 'by_username' to the API resource User +- i18n. + - Add deutsch (de) translation. + - Add nederlands (nl) translation. + +### Misc +- Lots of small and not so small bugfixes. + + +## 1.7.0 Empetrum Nigrum (2015-05-21) + +### Features +- Make Taiga translatable (i18n support). +- i18n. + - Add spanish (es) translation. + - Add french (fr) translation. + - Add finish (fi) translation. + - Add catalan (ca) translation. + - Add traditional chinese (zh-Hant) translation. +- Add Jitsi to our supported videoconference apps list +- Add tags field to CSV reports. +- Improve history (and email) comments created by all the GitHub actions + +### Misc +- New contrib plugin for letschat (by Δndrea Stagi) +- Remove djangorestframework from requirements. Move useful code to core. +- Lots of small and not so small bugfixes. + + +## 1.6.0 Abies Bifolia (2015-03-17) + +### Features +- Added custom fields per project for user stories, tasks and issues. +- Support of export to CSV user stories, tasks and issues. +- Allow public projects. + +### Misc +- New contrib plugin for HipChat (by Δndrea Stagi). +- Lots of small and not so small bugfixes. +- Updated some requirements. + + +## 1.5.0 Betula Pendula - FOSDEM 2015 (2015-01-29) + +### Features +- Improving SQL queries and performance. +- Now you can export and import projects between Taiga instances. +- Email redesign. +- Support for archived status (not shown by default in Kanban). +- Removing files from filesystem when deleting attachments. +- Support for contrib plugins (existing yet: slack, hall and gogs). +- Webhooks added (crazy integrations are welcome). + +### Misc +- Lots of small and not so small bugfixes. + + +## 1.4.0 Abies veitchii (2014-12-10) + +### Features +- Bitbucket integration: + + Change status of user stories, tasks and issues with the commit messages. +- Gitlab integration: + + Change status of user stories, tasks and issues with the commit messages. + + Sync issues creation in Taiga from Gitlab. +- Support throttling. + + for anonymous users + + for authenticated users + + in import mode +- Add project members stats endpoint. +- Support of leave project. +- Control of leave a project without admin user. +- Improving OCC (Optimistic concurrency control) +- Improving some SQL queries using djrom directly + +### Misc +- Lots of small and not so small bugfixes. + + +## 1.3.0 Dryas hookeriana (2014-11-18) + +### Features +- GitHub integration (Phase I): + + Login/singin connector. + + Change status of user stories, tasks and issues with the commit messages. + + Sync issues creation in Taiga from GitHub. + + Sync comments in Taiga from GitHub issues. + +### Misc +- Lots of small and not so small bugfixes. + + +## 1.2.0 Picea obovata (2014-11-04) + +### Features +- Send an email to the user on signup. +- Emit django signal on user signout. +- Support for custom text when inviting users. + +### Misc +- Lots of small and not so small bugfixes. + + +## 1.1.0 Alnus maximowiczii (2014-10-13) + +### Misc +- Fix bugs related to unicode chars on attachments. +- Fix wrong static url resolve usage on emails. +- Fix some bugs on import/export api related with attachments. + + +## 1.0.0 (2014-10-07) + +### Misc +- Lots of small and not so small bugfixes + +### Features +- New data exposed in the API for taskboard and backlog summaries +- Allow feedback for users from the platform +- Real time changes for backlog, taskboard, kanban and issues +>>>>>>> upstream/stable diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34a97dcc6..305eccf32 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,11 @@ There are many different ways to contribute to Taiga's platform, from patches, t ## Issues +<<<<<<< HEAD If you find a bug in the source code, you can help us by [submitting an issue](https://github.com/taigaio/taiga-docker/issues/new/choose). Even better, you can submit a Pull Request with a fix. +======= +If you find a bug in the source code, you can help us by [submitting an issue](https://github.com/taigaio/taiga-back/issues/new/choose). Even better, you can submit a Pull Request with a fix. +>>>>>>> upstream/stable ## Commit Message Guidelines diff --git a/README.md b/README.md index f2f064080..9bda05e3c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Taiga Docker | :exclamation: | We recently announced Taiga plans for the future and they greatly affect how we manage this repository and the current Taiga 6 release. Check it [here](https://blog.taiga.io/announcing_taiganext.html). | @@ -51,6 +52,15 @@ It's developed by the same team behind Taiga. If you want to give it a try, you ![Penpot screenshot](imgs/penpot.jpg) And finally if you just want to launch Taiga standalone, you can use the `launch-taiga.sh` script instead of the `launch-all.sh`. +======= +## Taiga Backend + +> **READ THIS FIRST!**: We recently announced Taiga plans for the future and they greatly affect how we manage this repository and the current Taiga 6 release. Check it [here](https://blog.taiga.io/announcing_taiganext.html). + +[![Managed with Taiga.io](https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg)](https://tree.taiga.io/project/taiga/ "Managed with Taiga.io") +[![Tests Status](https://github.com/taigaio/taiga-back/workflows/Taiga%20Back%20-%20Test%20and%20Coverage/badge.svg?branch=main)](https://github.com/taigaio/taiga-back/actions?query=workflow%3A%22Taiga+Back+-+Test+and+Coverage%22 "Tests Status") +[![Coverage Status](https://img.shields.io/coveralls/taigaio/taiga-back/main.svg)](https://coveralls.io/r/taigaio/taiga-back?branch=main "Coverage Status") +>>>>>>> upstream/stable ## Documentation @@ -58,14 +68,22 @@ Currently, we have authored three main documentation hubs: - **[API](https://docs.taiga.io/api.html)**: Our API documentation and reference for developing from Taiga API. - **[Documentation](https://docs.taiga.io/)**: If you need to install Taiga on your own server, this is the place to find some guides. +<<<<<<< HEAD - **[Taiga Community](https://community.taiga.io/)**: This page is intended to be the support reference page for the users. +======= +- **[Taiga Resources](https://community.taiga.io/)**: This page is intended to be the support reference page for the users. +>>>>>>> upstream/stable ## Bug reports If you **find a bug** in Taiga you can always report it: - in [Taiga issues](https://tree.taiga.io/project/taiga/issues). **This is the preferred way** +<<<<<<< HEAD - in [Github issues](https://github.com/taigaio/taiga-docker/issues) +======= +- in [Github issues](https://github.com/taigaio/taiga-back/issues) +>>>>>>> upstream/stable - send us a mail to support@taiga.io if is a bug related to [tree.taiga.io](https://tree.taiga.io) - send us a mail to security@taiga.io if is a **security bug** @@ -81,7 +99,11 @@ If you want to be up to date about announcements of releases, important changes ## Contribute to Taiga +<<<<<<< HEAD There are many different ways to contribute to Taiga's platform, from patches, to documentation and UI enhancements, just find the one that best fits with your skills. Check out our detailed [contribution guide](https://community.taiga.io/t/how-can-i-contribute/159#code-patches-enhacements-3). +======= +There are many different ways to contribute to Taiga's platform, from patches, to documentation and UI enhancements, just find the one that best fits with your skills. Check out our detailed [contribution guide](https://community.taiga.io/t/how-can-i-contribute/159) +>>>>>>> upstream/stable ## Code of Conduct @@ -91,6 +113,7 @@ Help us keep the Taiga Community open and inclusive. Please read and follow our Every code patch accepted in Taiga codebase is licensed under [MPL 2.0](LICENSE). You must be careful to not include any code that can not be licensed under this license. +<<<<<<< HEAD Please read carefully [our license](LICENSE) and ask us if you have any questions as well as the [Contribution policy](https://github.com/taigaio/taiga-docker/blob/main/CONTRIBUTING.md). ## Configuration @@ -539,3 +562,6 @@ server { ## Change between subpath and subdomain If you're changing Taiga configuration from default subdomain (https://taiga.mycompany.com) to subpath (http://mycompany.com/subpath) or vice versa, on top of adjusting the configuration as said above, you should consider changing the TAIGA_SECRET_KEY so the refresh works properly for the end user. +======= +Please read carefully [our license](LICENSE) and ask us if you have any questions as well as the [Contribution policy](https://github.com/taigaio/taiga-back/blob/main/CONTRIBUTING.md). +>>>>>>> upstream/stable diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..728ef8aec --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,91 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +FROM python:3.11-slim +LABEL maintainer="support@taiga.io" + +# Avoid prompting for configuration +ENV DEBIAN_FRONTEND=noninteractive + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONFAULTHANDLER=1 + +# Use a virtualenv +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Get the code +COPY . /taiga-back +WORKDIR /taiga-back + +# grab gosu for easy step-down from root +# https://github.com/tianon/gosu/blob/master/INSTALL.md +ENV GOSU_VERSION 1.12 + +RUN set -eux; \ + apt-get update; \ + # install system dependencies + apt-get install -y \ + build-essential \ + gettext \ + # libpq5 needed in runtime for psycopg2 + libpq5 \ + libpq-dev \ + git \ + net-tools \ + procps \ + wget; \ + # install gosu + apt-get install -y --no-install-recommends ca-certificates wget; \ + dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + chmod +x /usr/local/bin/gosu; \ + # verify gosu signature + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + # install Taiga dependencies + python -m pip install --upgrade pip; \ + python -m pip install wheel; \ + python -m pip install -r requirements.txt; \ + python -m pip install -r requirements-contribs.txt; \ + python manage.py compilemessages; \ + python manage.py collectstatic --no-input; \ + chmod +x docker/entrypoint.sh; \ + chmod +x docker/async_entrypoint.sh; \ + cp docker/config.py settings/config.py; \ + # create taiga group and user to use it and give permissions over the code (in entrypoint) + groupadd --system taiga --gid=999; \ + useradd --system --no-create-home --gid taiga --uid=999 --shell=/bin/bash taiga; \ + mkdir -p /taiga-back/media/exports; \ + chown -R taiga:taiga /taiga-back; \ + # remove unneeded files and packages + apt-get purge -y \ + build-essential \ + gettext \ + git \ + libpq-dev \ + net-tools \ + procps \ + wget; \ + apt-get autoremove -y; \ + rm -rf /var/lib/apt/lists/*; \ + rm -rf /root/.cache; \ + # clean taiga + rm requirements.txt; \ + rm requirements-contribs.txt; \ + find . -name '__pycache__' -exec rm -r '{}' +; \ + find . -name '*pyc' -exec rm -r '{}' +; \ + find . -name '*po' -exec rm -r '{}' + + +ENV DJANGO_SETTINGS_MODULE=settings.config + +EXPOSE 8000 +ENTRYPOINT ["./docker/entrypoint.sh"] diff --git a/docker/async_entrypoint.sh b/docker/async_entrypoint.sh new file mode 100644 index 000000000..ae27a297e --- /dev/null +++ b/docker/async_entrypoint.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +set -euo pipefail + +# Give permission to taiga:taiga after mounting volumes +echo Give permission to taiga:taiga +chown -R taiga:taiga /taiga-back + +# Start Celery processes +echo Starting Celery... +exec gosu taiga celery -A taiga.celery worker -B \ + --concurrency 4 \ + -l INFO \ + "$@" diff --git a/docker/config.py b/docker/config.py new file mode 100644 index 000000000..bb076d168 --- /dev/null +++ b/docker/config.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from .common import * +import os + + +######################################### +## GENERIC +######################################### + +DEBUG = os.getenv('DEBUG', 'False') == 'True' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('POSTGRES_DB'), + 'USER': os.getenv('POSTGRES_USER'), + 'PASSWORD': os.getenv('POSTGRES_PASSWORD'), + 'HOST': os.getenv('POSTGRES_HOST'), + 'PORT': os.getenv('POSTGRES_PORT','5432'), + 'OPTIONS': {'sslmode': os.getenv('POSTGRES_SSLMODE','disable')}, + 'DISABLE_SERVER_SIDE_CURSORS': os.getenv('POSTGRES_DISABLE_SERVER_SIDE_CURSORS', 'False') == 'True', + } +} +SECRET_KEY = os.getenv('TAIGA_SECRET_KEY') + +TAIGA_SITES_SCHEME = os.getenv('TAIGA_SITES_SCHEME') +TAIGA_SITES_DOMAIN = os.getenv('TAIGA_SITES_DOMAIN') +FORCE_SCRIPT_NAME = os.getenv('TAIGA_SUBPATH', '') + +TAIGA_URL = f"{ TAIGA_SITES_SCHEME }://{ TAIGA_SITES_DOMAIN }{ FORCE_SCRIPT_NAME }" +SITES = { + "api": { "name": "api", "scheme": TAIGA_SITES_SCHEME, "domain": TAIGA_SITES_DOMAIN }, + "front": { "name": "front", "scheme": TAIGA_SITES_SCHEME, "domain": f"{ TAIGA_SITES_DOMAIN }{ FORCE_SCRIPT_NAME }" } +} + +LANGUAGE_CODE = os.getenv("LANGUAGE_CODE", "en-us") + +INSTANCE_TYPE = "D" + +WEBHOOKS_ENABLED = os.getenv('WEBHOOKS_ENABLED', 'True') == 'True' +WEBHOOKS_ALLOW_PRIVATE_ADDRESS = os.getenv('WEBHOOKS_ALLOW_PRIVATE_ADDRESS', 'False') == 'True' +WEBHOOKS_ALLOW_REDIRECTS = os.getenv('WEBHOOKS_ALLOW_REDIRECTS', 'False') == 'True' + +# Setting DEFAULT_PROJECT_SLUG_PREFIX to false +# removes the username from project slug +DEFAULT_PROJECT_SLUG_PREFIX = os.getenv('DEFAULT_PROJECT_SLUG_PREFIX', 'False') == 'True' + +######################################### +## MEDIA +######################################### +MEDIA_URL = f"{ TAIGA_URL }/media/" +DEFAULT_FILE_STORAGE = "taiga_contrib_protected.storage.ProtectedFileSystemStorage" +THUMBNAIL_DEFAULT_STORAGE = DEFAULT_FILE_STORAGE + +STATIC_URL = f"{ TAIGA_URL }/static/" + + +######################################### +## EMAIL +######################################### +# https://docs.djangoproject.com/en/3.1/topics/email/ +EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.console.EmailBackend') +CHANGE_NOTIFICATIONS_MIN_INTERVAL = 120 # seconds + +DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'system@taiga.io') +EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'False') == 'True' +EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'False') == 'True' +EMAIL_HOST = os.getenv('EMAIL_HOST', 'localhost') +EMAIL_PORT = os.getenv('EMAIL_PORT', 587) +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', 'user') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', 'password') + + +######################################### +## SESSION +######################################### +SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'True') == 'True' +CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', 'True') == 'True' + + +######################################### +## EVENTS +######################################### +EVENTS_PUSH_BACKEND = "taiga.events.backends.rabbitmq.EventsPushBackend" + +EVENTS_PUSH_BACKEND_URL = os.getenv('EVENTS_PUSH_BACKEND_URL') +if not EVENTS_PUSH_BACKEND_URL: + EVENTS_PUSH_BACKEND_URL = f"amqp://{ os.getenv('RABBITMQ_USER') }:{ os.getenv('RABBITMQ_PASS') }@{ os.getenv('TAIGA_EVENTS_RABBITMQ_HOST', 'taiga-events-rabbitmq') }:5672/taiga" + +EVENTS_PUSH_BACKEND_OPTIONS = { + "url": EVENTS_PUSH_BACKEND_URL +} + + +######################################### +## TAIGA ASYNC +######################################### +CELERY_ENABLED = os.getenv('CELERY_ENABLED', 'True') == 'True' +from kombu import Queue # noqa + +CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL') +if not CELERY_BROKER_URL: + CELERY_BROKER_URL = f"amqp://{ os.getenv('RABBITMQ_USER') }:{ os.getenv('RABBITMQ_PASS') }@{ os.getenv('TAIGA_ASYNC_RABBITMQ_HOST', 'taiga-async-rabbitmq') }:5672/taiga" + +CELERY_RESULT_BACKEND = None # for a general installation, we don't need to store the results +CELERY_ACCEPT_CONTENT = ['pickle', ] # Values are 'pickle', 'json', 'msgpack' and 'yaml' +CELERY_TASK_SERIALIZER = "pickle" +CELERY_RESULT_SERIALIZER = "pickle" +CELERY_TIMEZONE = os.getenv('CELERY_TIMEZONE', 'Europe/Madrid') +CELERY_TASK_DEFAULT_QUEUE = 'tasks' +CELERY_QUEUES = ( + Queue('tasks', routing_key='task.#'), + Queue('transient', routing_key='transient.#', delivery_mode=1) +) +CELERY_TASK_DEFAULT_EXCHANGE = 'tasks' +CELERY_TASK_DEFAULT_EXCHANGE_TYPE = 'topic' +CELERY_TASK_DEFAULT_ROUTING_KEY = 'task.default' + + +######################################### +## REGISTRATION +######################################### +PUBLIC_REGISTER_ENABLED = os.getenv('PUBLIC_REGISTER_ENABLED', 'False') == 'True' + + +######################################### +## CONTRIBS +######################################### + +# SLACK +ENABLE_SLACK = os.getenv('ENABLE_SLACK', 'False') == 'True' +if ENABLE_SLACK: + INSTALLED_APPS += [ + "taiga_contrib_slack" + ] + +# GITHUB AUTH +# WARNING: If PUBLIC_REGISTER_ENABLED == False, currently Taiga by default prevents the OAuth +# buttons to appear for both login and register +ENABLE_GITHUB_AUTH = os.getenv('ENABLE_GITHUB_AUTH', 'False') == 'True' +if PUBLIC_REGISTER_ENABLED and ENABLE_GITHUB_AUTH: + INSTALLED_APPS += [ + "taiga_contrib_github_auth" + ] + GITHUB_API_CLIENT_ID = os.getenv('GITHUB_API_CLIENT_ID') + GITHUB_API_CLIENT_SECRET = os.getenv('GITHUB_API_CLIENT_SECRET') + +# GITLAB AUTH +# WARNING: If PUBLIC_REGISTER_ENABLED == False, currently Taiga by default prevents the OAuth +# buttons to appear for both login and register +ENABLE_GITLAB_AUTH = os.getenv('ENABLE_GITLAB_AUTH', 'False') == 'True' +if PUBLIC_REGISTER_ENABLED and ENABLE_GITLAB_AUTH: + INSTALLED_APPS += [ + "taiga_contrib_gitlab_auth" + ] + GITLAB_API_CLIENT_ID = os.getenv('GITLAB_API_CLIENT_ID') + GITLAB_API_CLIENT_SECRET = os.getenv('GITLAB_API_CLIENT_SECRET') + GITLAB_URL = os.getenv('GITLAB_URL') + + +######################################### +## TELEMETRY +######################################### +ENABLE_TELEMETRY = os.getenv('ENABLE_TELEMETRY', 'True') == 'True' + + +######################################### +## IMPORTERS +######################################### +ENABLE_GITHUB_IMPORTER = os.getenv('ENABLE_GITHUB_IMPORTER', 'False') == 'True' +if ENABLE_GITHUB_IMPORTER: + IMPORTERS["github"] = { + "active": True, + "client_id": os.getenv('GITHUB_IMPORTER_CLIENT_ID'), + "client_secret": os.getenv('GITHUB_IMPORTER_CLIENT_SECRET') + } + +ENABLE_JIRA_IMPORTER = os.getenv('ENABLE_JIRA_IMPORTER', 'False') == 'True' +if ENABLE_JIRA_IMPORTER: + IMPORTERS["jira"] = { + "active": True, + "consumer_key": os.getenv('JIRA_IMPORTER_CONSUMER_KEY'), + "cert": os.getenv('JIRA_IMPORTER_CERT'), + "pub_cert": os.getenv('JIRA_IMPORTER_PUB_CERT') + } + +ENABLE_TRELLO_IMPORTER = os.getenv('ENABLE_TRELLO_IMPORTER', 'False') == 'True' +if ENABLE_TRELLO_IMPORTER: + IMPORTERS["trello"] = { + "active": True, + "api_key": os.getenv('TRELLO_IMPORTER_API_KEY'), + "secret_key": os.getenv('TRELLO_IMPORTER_SECRET_KEY') + } diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 000000000..9d1b0f480 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +set -euo pipefail + +# Execute pending migrations +echo Executing pending migrations +python manage.py migrate + +# Load default templates +echo Load default templates +python manage.py loaddata initial_project_templates + +# Give permission to taiga:taiga after mounting volumes +echo Give permission to taiga:taiga +chown -R taiga:taiga /taiga-back + +# Start Taiga processes +echo Starting Taiga API... +exec gosu taiga gunicorn taiga.wsgi:application \ + --name taiga_api \ + --bind 0.0.0.0:8000 \ + --workers 3 \ + --worker-tmp-dir /dev/shm \ + --log-level=info \ + --access-logfile - \ + "$@" diff --git a/docs/update-dependencies.md b/docs/update-dependencies.md new file mode 100644 index 000000000..e0e41fab4 --- /dev/null +++ b/docs/update-dependencies.md @@ -0,0 +1,13 @@ +# Update dependencies + +We can check [Dependabot](https://github.com/taigaio/taiga-back/security/dependabot) to keep updated on dependencies alerts. + +To update major dependencies: +- edit `requirements.in` and `requirements-devel.in` to update the version scopes if needed +- activate virtualenv +- launch `./scripts/upgrade` script + +This script will: +- update `requirement` files with the latest versiones acording to the `.in` files +- install these latest versions in the virtualenv +- test the new dependencies and commit them when they are ready diff --git a/manage.py b/manage.py new file mode 100755 index 000000000..d1f533ec1 --- /dev/null +++ b/manage.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.common") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..6efc8deda --- /dev/null +++ b/pytest.ini @@ -0,0 +1,10 @@ +[pytest] +DJANGO_SETTINGS_MODULE = tests.config +filterwarnings = + # Django + ignore::django.utils.deprecation.RemovedInDjango40Warning + ignore::django.utils.deprecation.RemovedInDjango41Warning + ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning + ignore:Use setlocale\(\), getencoding\(\) and getlocale\(\) instead:DeprecationWarning + # PyJWT + ignore::jwt.warnings.RemovedInPyjwt3Warning diff --git a/regenerate.sh b/regenerate.sh new file mode 100755 index 000000000..d7d124ad9 --- /dev/null +++ b/regenerate.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +show_answer=true +while [ $# -gt 0 ]; do + case "$1" in + -y) + show_answer=false + ;; + esac + shift +done + +if $show_answer ; then + echo "WARNING!! This script REMOVE your Taiga's database and you LOSE all the data." + read -p "Are you sure you want to delete all data? (Press Y to continue): " -n 1 -r + echo # (optional) move to a new line + if [[ ! $REPLY =~ ^[Yy]$ ]] ; then + exit 1 + fi +fi + +read -p 'Specify a Postgres user [default: taiga]: ' dbuser +read -p 'Specify database name [default: taiga]: ' dbname +read -p 'Specify host [default: 127.0.0.1]: ' dbhost +read -p 'Specify port [default: 5432]: ' dbport +dbuser=${dbuser:-taiga} +dbname=${dbname:-taiga} +dbhost=${dbhost:-127.0.0.1} +dbport=${dbport:-5432} + +echo "-> Remove '${dbname}' DB" +dropdb -U $dbuser -h $dbhost -p $dbport $dbname +echo "-> Create '${dbname}' DB" +createdb -U $dbuser -h $dbhost -p $dbport $dbname + +if [ "$?" -ne "0" ]; then + echo && echo "Error accessing the database, aborting." +else + echo "-> Load migrations" + python manage.py migrate + python manage.py createcachetable + echo "-> Load initial user (admin/123123)" + python manage.py loaddata initial_user --traceback + echo "-> Load initial project_templates (scrum/kanban)" + python manage.py loaddata initial_project_templates --traceback + echo "-> Generate sample data" + python manage.py sample_data --traceback + echo "-> Rebuilding timeline" + python manage.py rebuild_timeline --purge +fi diff --git a/requirements-contribs.in b/requirements-contribs.in new file mode 100644 index 000000000..59d8665d6 --- /dev/null +++ b/requirements-contribs.in @@ -0,0 +1,4 @@ +git+https://github.com/taigaio/taiga-contrib-github-auth.git@6.8.0#egg=taiga-contrib-github-auth&subdirectory=back +git+https://github.com/taigaio/taiga-contrib-gitlab-auth.git@6.8.0#egg=taiga-contrib-gitlab-auth-official&subdirectory=back +git+https://github.com/taigaio/taiga-contrib-slack.git@6.8.0#egg=taiga-contrib-slack&subdirectory=back +git+https://github.com/taigaio/taiga-contrib-protected.git@6.8.0#egg=taiga-contrib-protected diff --git a/requirements-contribs.txt b/requirements-contribs.txt new file mode 100644 index 000000000..bcf44a3f0 --- /dev/null +++ b/requirements-contribs.txt @@ -0,0 +1,14 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile requirements-contribs.in +# +taiga-contrib-github-auth @ git+https://github.com/taigaio/taiga-contrib-github-auth.git@6.8.0#subdirectory=back + # via -r requirements-contribs.in +taiga-contrib-gitlab-auth-official @ git+https://github.com/taigaio/taiga-contrib-gitlab-auth.git@6.8.0#subdirectory=back + # via -r requirements-contribs.in +taiga-contrib-protected @ git+https://github.com/taigaio/taiga-contrib-protected.git@6.8.0 + # via -r requirements-contribs.in +taiga-contrib-slack @ git+https://github.com/taigaio/taiga-contrib-slack.git@6.8.0#subdirectory=back + # via -r requirements-contribs.in diff --git a/requirements-devel.in b/requirements-devel.in new file mode 100644 index 000000000..17159b22f --- /dev/null +++ b/requirements-devel.in @@ -0,0 +1,7 @@ +-c requirements.txt +coverage +coveralls +pytest +pytest-django +factory-boy +python-jose>=3.0.0 diff --git a/requirements-devel.txt b/requirements-devel.txt new file mode 100644 index 000000000..f5103b98e --- /dev/null +++ b/requirements-devel.txt @@ -0,0 +1,81 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile requirements-devel.in +# +attrs==22.2.0 + # via + # -c requirements.txt + # pytest +certifi==2022.12.7 + # via + # -c requirements.txt + # requests +charset-normalizer==2.0.12 + # via + # -c requirements.txt + # requests +coverage==6.5.0 + # via + # -r requirements-devel.in + # coveralls +coveralls==3.3.1 + # via -r requirements-devel.in +docopt==0.6.2 + # via + # -c requirements.txt + # coveralls +ecdsa==0.18.0 + # via python-jose +exceptiongroup==1.1.0 + # via pytest +factory-boy==3.2.1 + # via -r requirements-devel.in +faker==17.0.0 + # via factory-boy +idna==3.4 + # via + # -c requirements.txt + # requests +iniconfig==2.0.0 + # via pytest +packaging==23.0 + # via + # -c requirements.txt + # pytest +pluggy==1.0.0 + # via pytest +pyasn1==0.4.8 + # via + # python-jose + # rsa +pytest==7.2.1 + # via + # -r requirements-devel.in + # pytest-django +pytest-django==4.5.2 + # via -r requirements-devel.in +python-dateutil==2.7.5 + # via + # -c requirements.txt + # faker +python-jose==3.3.0 + # via -r requirements-devel.in +requests==2.27.1 + # via + # -c requirements.txt + # coveralls +rsa==4.9 + # via python-jose +six==1.16.0 + # via + # -c requirements.txt + # ecdsa + # python-dateutil +tomli==2.0.1 + # via pytest +urllib3==1.26.14 + # via + # -c requirements.txt + # requests diff --git a/requirements-tests.txt b/requirements-tests.txt new file mode 100644 index 000000000..5aed9efa1 --- /dev/null +++ b/requirements-tests.txt @@ -0,0 +1,2 @@ +factory-boy==3.2.1 +pytest==7.2.1 diff --git a/requirements.in b/requirements.in new file mode 100644 index 000000000..acd10b345 --- /dev/null +++ b/requirements.in @@ -0,0 +1,38 @@ +asana +bleach<5 +celery==5.2.7 +diff-match-patch==20121119 +django-ipware==1.1.6 +django-jinja +django-picklefield==3.1 +django-pglocks==1.0.4 +django-sampledatahelper==0.4.1 +django-sites==0.11 +django-sr==0.0.4 +djmail==2.0.0 +easy-thumbnails==2.8.5 +gunicorn==20.1.0 +netaddr<0.9 +premailer==3.0.1 +psd-tools==1.9.18 +psycopg2<2.10 # required by django +python-dateutil==2.7.5 +python-magic==0.4.15 +pytz +raven +redis==4.5.3 +requests==2.27.1 +requests_oauthlib +rudder-sdk-python==1.0.0b1 +serpy==0.1.1 +webcolors==1.9.1 +CairoSVG>=2.5.1 +Django>=3.2.19,<4 +Markdown==3.4.1 +pymdown-extensions==9.9.2 +Pillow +Unidecode==0.4.20 +Pygments>=2.7.4 +oauthlib[signedtoken] +html5lib +zipp<2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..d0c2db707 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,246 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile requirements.in +# +aggdraw==1.3.15 + # via psd-tools +amqp==5.1.1 + # via kombu +asana==3.1.0 + # via -r requirements.in +asgiref==3.6.0 + # via django +async-timeout==4.0.2 + # via redis +attrs==22.2.0 + # via psd-tools +backoff==1.6.0 + # via rudder-sdk-python +billiard==3.6.4.0 + # via celery +bleach==4.1.0 + # via -r requirements.in +cairocffi==1.4.0 + # via cairosvg +cairosvg==2.6.0 + # via -r requirements.in +celery==5.2.7 + # via -r requirements.in +certifi==2022.12.7 + # via requests +cffi==1.15.1 + # via + # cairocffi + # cryptography +charset-normalizer==2.0.12 + # via requests +click==8.1.3 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl +click-didyoumean==0.3.0 + # via celery +click-plugins==1.1.1 + # via celery +click-repl==0.2.0 + # via celery +cryptography==39.0.1 + # via oauthlib +cssselect==1.2.0 + # via premailer +cssselect2==0.7.0 + # via cairosvg +cssutils==2.6.0 + # via premailer +defusedxml==0.7.1 + # via cairosvg +diff-match-patch==20121119 + # via -r requirements.in +django==3.2.19 + # via + # -r requirements.in + # django-jinja + # django-picklefield + # django-sampledatahelper + # django-sites + # django-sr + # easy-thumbnails +django-ipware==1.1.6 + # via -r requirements.in +django-jinja==2.10.2 + # via -r requirements.in +django-pglocks==1.0.4 + # via -r requirements.in +django-picklefield==3.1 + # via -r requirements.in +django-sampledatahelper==0.4.1 + # via -r requirements.in +django-sites==0.11 + # via -r requirements.in +django-sr==0.0.4 + # via -r requirements.in +djmail==2.0.0 + # via -r requirements.in +docopt==0.6.2 + # via psd-tools +easy-thumbnails==2.8.5 + # via -r requirements.in +gunicorn==20.1.0 + # via -r requirements.in +html5lib==1.1 + # via -r requirements.in +idna==3.4 + # via requests +imageio==2.25.1 + # via scikit-image +importlib-metadata==6.0.0 + # via markdown +jinja2==3.1.2 + # via django-jinja +kombu==5.2.4 + # via celery +lxml==4.9.2 + # via premailer +markdown==3.4.1 + # via + # -r requirements.in + # pymdown-extensions +markupsafe==2.1.2 + # via jinja2 +monotonic==1.6 + # via rudder-sdk-python +netaddr==0.8.0 + # via -r requirements.in +networkx==3.0 + # via scikit-image +numpy==1.24.2 + # via + # imageio + # psd-tools + # pywavelets + # scikit-image + # scipy + # tifffile +oauthlib[signedtoken]==3.2.2 + # via + # -r requirements.in + # requests-oauthlib +packaging==23.0 + # via + # bleach + # scikit-image +pillow==9.4.0 + # via + # -r requirements.in + # cairosvg + # easy-thumbnails + # imageio + # psd-tools + # scikit-image +premailer==3.0.1 + # via -r requirements.in +prompt-toolkit==3.0.36 + # via click-repl +psd-tools==1.9.18 + # via -r requirements.in +psycopg2==2.9.5 + # via -r requirements.in +pycparser==2.21 + # via cffi +pygments==2.14.0 + # via -r requirements.in +pyjwt==2.6.0 + # via oauthlib +pymdown-extensions==9.9.2 + # via -r requirements.in +python-dateutil==2.7.5 + # via + # -r requirements.in + # rudder-sdk-python +python-magic==0.4.15 + # via -r requirements.in +pytz==2022.7.1 + # via + # -r requirements.in + # celery + # django + # sampledata +pywavelets==1.4.1 + # via scikit-image +raven==6.10.0 + # via -r requirements.in +redis==4.5.3 + # via -r requirements.in +requests==2.27.1 + # via + # -r requirements.in + # asana + # premailer + # requests-oauthlib + # rudder-sdk-python +requests-oauthlib==1.3.1 + # via + # -r requirements.in + # asana +rudder-sdk-python==1.0.0b1 + # via -r requirements.in +sampledata==0.3.7 + # via django-sampledatahelper +scikit-image==0.19.3 + # via psd-tools +scipy==1.10.0 + # via + # psd-tools + # scikit-image +serpy==0.1.1 + # via -r requirements.in +six==1.16.0 + # via + # bleach + # click-repl + # django-pglocks + # django-sampledatahelper + # html5lib + # python-dateutil + # rudder-sdk-python + # sampledata + # serpy + # webcolors +sqlparse==0.4.3 + # via django +tifffile==2023.2.3 + # via scikit-image +tinycss2==1.2.1 + # via + # cairosvg + # cssselect2 +unidecode==0.4.20 + # via -r requirements.in +urllib3==1.26.14 + # via requests +vine==5.0.0 + # via + # amqp + # celery + # kombu +wcwidth==0.2.6 + # via prompt-toolkit +webcolors==1.9.1 + # via -r requirements.in +webencodings==0.5.1 + # via + # bleach + # cssselect2 + # html5lib + # tinycss2 +zipp==1.2.0 + # via + # -r requirements.in + # importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/scripts/generate_fixtures_initial_project_templates.sh b/scripts/generate_fixtures_initial_project_templates.sh new file mode 100755 index 000000000..db86fd2c4 --- /dev/null +++ b/scripts/generate_fixtures_initial_project_templates.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +python ./manage.py dumpdata --format json \ + --indent 4 \ + --output './taiga/projects/fixtures/initial_project_templates.json' \ + 'projects.ProjectTemplate' diff --git a/scripts/install b/scripts/install new file mode 100755 index 000000000..188c9741c --- /dev/null +++ b/scripts/install @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euxo pipefail +cd "$(dirname "$0")/.." +python3 -m venv .venv +.venv/bin/python -m pip install --upgrade pip wheel setuptools +.venv/bin/python -m pip install --upgrade pip-tools +.venv/bin/python -m pip install -r requirements.txt -r requirements-devel.txt diff --git a/scripts/manage_translations.py b/scripts/manage_translations.py new file mode 100755 index 000000000..c75dcfced --- /dev/null +++ b/scripts/manage_translations.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# NOTE: This script is based on django's manage_translations.py script +# (https://github.com/django/django/blob/master/scripts/manage_translations.py) +# +# This python file contains utility scripts to manage taiga translations. +# It has to be run inside the taiga-back git root directory. +# +# The following commands are available: +# +# * update_catalogs: check for new strings in taiga-back catalogs, and +# output how much strings are new/changed. +# +# * lang_stats: output statistics for each catalog/language combination +# +# * fetch: fetch translations from transifex.com +# +# * commit: update resources in transifex.com with the local files +# +# Each command support the --languages and --resources options to limit their +# operation to the specified language or resource. For example, to get stats +# for Spanish in contrib.admin, run: +# +# $ python scripts/manage_translations.py lang_stats --language=es --resources=taiga + +import os, errno +from argparse import ArgumentParser +from argparse import RawTextHelpFormatter + +from subprocess import PIPE, Popen, call + +from django.core.management import call_command +from django_jinja.management.commands import makemessages + +import django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.common') +django.setup() + + +SOURCE_LANG = "en" + +def _get_locale_dirs(resources): + """ + Return a tuple (app name, absolute path) for all locale directories. + If resources list is not None, filter directories matching resources content. + """ + contrib_dir = os.getcwd() + dirs = [] + + # Collect all locale directories + for contrib_name in os.listdir(contrib_dir): + path = os.path.join(contrib_dir, contrib_name, "locale") + if os.path.isdir(path): + dirs.append((contrib_name, path)) + + # Filter by resources, if any + if resources is not None: + res_names = [d[0] for d in dirs] + dirs = [ld for ld in dirs if ld[0] in resources] + if len(resources) > len(dirs): + print("You have specified some unknown resources. " + "Available resource names are: {0}".format(", ".join(res_names))) + exit(1) + + return dirs + + +def _tx_resource_for_name(name): + """ Return the Transifex resource name """ + return "taiga-back.{}".format(name) + + +def _check_diff(cat_name, lang, base_path): + """ + Output the approximate number of changed/added strings in the en catalog. + """ + po_path = "{path}/{lang}/LC_MESSAGES/django.po".format(path=base_path, lang=lang) + p = Popen("git diff -U0 {0} | egrep '^[-+]msgid' | wc -l".format(po_path), + stdout=PIPE, stderr=PIPE, shell=True) + output, errors = p.communicate() + num_changes = int(output.strip()) + print("{0} changed/added messages in '{1}' for catalog '{2}'".format(num_changes, lang, cat_name)) + + +def _makemessages(languages): + cmd = makemessages.Command() + opts = { + "locale": languages, + "extensions": ["py", "jinja"], + } + call_command(cmd, **opts) + + +def add_lang(resources=None, languages=None): + """ + Create .po files for new language(s) + """ + if not languages: + print("ERROR: Specify at least one language") + exit(1) + + locale_dirs = _get_locale_dirs(None) + errors = [] + + for name, dir_ in locale_dirs: + # check if langs exists + catalog_langs = sorted([d for d in os.listdir(dir_) if not d.startswith("_") and os.path.isdir(os.path.join(dir_, d))]) + if existing_langs := list(set(catalog_langs) & set(languages)): + print(f"ERROR: Lang(s) {existing_langs} just exist.") + exit(1) + + _makemessages(languages) + + +def update_catalogs(resources=None, languages=None): + """ + Update the existing .po files with + new/updated translatable strings. + """ + os.chdir(os.getcwd()) + + # Update all catalog + locale_dirs = _get_locale_dirs(None) + for name, dir_ in locale_dirs: + langs = languages or sorted([d for d in os.listdir(dir_) if not d.startswith("_") and os.path.isdir(os.path.join(dir_, d))]) + _makemessages(langs) + + for lang in langs: + _check_diff(name, lang, dir_) + + +def regenerate(resources=None, languages=None): + """ + Wrap long lines and generate mo files. + """ + locale_dirs = _get_locale_dirs(resources) + errors = [] + + for name, dir_ in locale_dirs: + if languages is None: + languages = sorted([d for d in os.listdir(dir_) if not d.startswith("_") and os.path.isdir(os.path.join(dir_, d)) and d != SOURCE_LANG]) + + for lang in languages: + po_path = "{path}/{lang}/LC_MESSAGES/django.po".format(path=dir_, lang=lang) + + if not os.path.exists(po_path): + print("No {lang} translation for resource {res}".format(lang=lang, res=name)) + continue + + call("msgcat -o {0} {0}".format(po_path), shell=True) + res = call("msgfmt -c -o {0}.mo {1}".format(po_path[:-3], po_path), shell=True) + + if res != 0: + errors.append((name, lang)) + + if errors: + print("\nWARNING: Errors have occurred in following cases:") + for resource, lang in errors: + print("\tResource {res} for language {lang}".format(res=resource, lang=lang)) + + exit(1) + + +def lang_stats(resources=None, languages=None): + """ + Output language statistics of committed translation files for each catalog. + If resources is provided, it should be a list of translation resource to + limit the output (e.g. ['main', 'taiga']). + """ + locale_dirs = _get_locale_dirs(resources) + + for name, dir_ in locale_dirs: + print("\nShowing translations stats for '{res}':".format(res=name)) + + langs = [] + for d in os.listdir(dir_): + if not d.startswith('_') and os.path.isdir(os.path.join(dir_, d)): + langs.append(d) + langs = sorted(langs) + + for lang in langs: + if languages and lang not in languages: + continue + + # TODO: merge first with the latest en catalog + p = Popen("msgfmt -vc -o /dev/null {path}/{lang}/LC_MESSAGES/django.po".format(path=dir_, lang=lang), + stdout=PIPE, stderr=PIPE, shell=True) + output, errors = p.communicate() + + if p.returncode == 0: + # msgfmt output stats on stderr + print("{0}: {1}".format(lang, errors.strip().decode("utf-8"))) + else: + print("Errors happened when checking {0} translation for {1}:\n{2}".format(lang, name, errors)) + + +if __name__ == "__main__": + RUNABLE_SCRIPTS = { + "add_lang": "Add a new language.", + "update_catalogs": "update .po files of all (default) or any language(s).", + "regenerate": "regenerate .mo files.", + "lang_stats": "get stats of local translations", + } + + parser = ArgumentParser(description="manage translations in taiga-back between the repo and transifex.", + formatter_class=RawTextHelpFormatter) + parser.add_argument("cmd", nargs=1, + help="\n".join(["{0} - {1}".format(c, h) for c, h in RUNABLE_SCRIPTS.items()])) + parser.add_argument("-r", "--resources", action="append", + help="limit operation to the specified resources") + parser.add_argument("-l", "--languages", action="append", + help="limit operation to the specified languages") + options = parser.parse_args() + + if options.cmd[0] in RUNABLE_SCRIPTS.keys(): + eval(options.cmd[0])(options.resources, options.languages) + else: + print("Available commands are: {}".format(", ".join(RUNABLE_SCRIPTS.keys()))) diff --git a/scripts/upgrade b/scripts/upgrade new file mode 100755 index 000000000..484fb99bb --- /dev/null +++ b/scripts/upgrade @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +if [[ -z ${VIRTUAL_ENV} ]]; then + echo "Please, activate first a virtualenv" + exit 1 +fi + +set -euxo pipefail +cd "$(dirname "$0")/.." +python -m piptools compile --upgrade requirements.in +python -m piptools compile --upgrade requirements-devel.in +python -m piptools sync requirements.txt requirements-devel.txt diff --git a/settings/__init__.py b/settings/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/settings/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/settings/common.py b/settings/common.py new file mode 100644 index 000000000..badf175c3 --- /dev/null +++ b/settings/common.py @@ -0,0 +1,685 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import os +import os.path +import sys +from datetime import timedelta + + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) + +APPEND_SLASH = False +ALLOWED_HOSTS = ["*"] + +ADMINS = ( + ("Admin", "example@example.com"), +) + +DEBUG = False + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "taiga", + "USER": "taiga", + "PASSWORD": "taiga", + "HOST": "127.0.0.1" + } +} + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "unique-snowflake" + } +} + +INSTANCE_TYPE = "SRC" + +# CELERY +CELERY_ENABLED = False +from kombu import Queue # noqa + +CELERY_BROKER_URL = 'amqp://guest:guest@localhost:5672//' +CELERY_RESULT_BACKEND = None # for a general installation, we don't need to store the results +CELERY_ACCEPT_CONTENT = ['pickle', ] # Values are 'pickle', 'json', 'msgpack' and 'yaml' +CELERY_TASK_SERIALIZER = "pickle" +CELERY_RESULT_SERIALIZER = "pickle" +CELERY_TIMEZONE = 'Europe/Madrid' +CELERY_TASK_DEFAULT_QUEUE = 'tasks' +CELERY_QUEUES = ( + Queue('tasks', routing_key='task.#'), + Queue('transient', routing_key='transient.#', delivery_mode=1) +) +CELERY_TASK_DEFAULT_EXCHANGE = 'tasks' +CELERY_TASK_DEFAULT_EXCHANGE_TYPE = 'topic' +CELERY_TASK_DEFAULT_ROUTING_KEY = 'task.default' + + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.PBKDF2PasswordHasher", +] + +# Default configuration for reverse proxy +USE_X_FORWARDED_HOST = True +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Errors report configuration +SEND_BROKEN_LINK_EMAILS = True +IGNORABLE_404_ENDS = (".php", ".cgi") +IGNORABLE_404_STARTS = ("/phpmyadmin/",) + +ATOMIC_REQUESTS = True +TIME_ZONE = "UTC" +LOGIN_URL = "/auth/login/" +USE_TZ = True + +USE_I18N = True +USE_L10N = True +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-us' + +# Languages we provide translations for, out of the box. +LANGUAGES = [ + #("af", "Afrikaans"), # Afrikaans + ("ar", "العربية‏"), # Arabic + #("ast", "Asturiano"), # Asturian + #("az", "Azərbaycan dili"), # Azerbaijani + #("bg", "Български"), # Bulgarian + #("be", "Беларуская"), # Belarusian + #("bn", "বাংলা"), # Bengali + #("br", "Bretón"), # Breton + #("bs", "Bosanski"), # Bosnian + ("ca", "Català"), # Catalan + # ("cs", "Čeština"), # Czech + # ("cy", "Cymraeg"), # Welsh + ("da", "Dansk"), # Danish + ("de", "Deutsch"), # German + # ("el", "Ελληνικά"), # Greek + ("en", "English (US)"), # English + # ("en-au", "English (Australia)"), # Australian English + # ("en-gb", "English (UK)"), # British English + # ("eo", "esperanta"), # Esperanto + ("es", "Español"), # Spanish + # ("es-ar", "Español (Argentina)"), # Argentinian Spanish + # ("es-mx", "Español (México)"), # Mexican Spanish + # ("es-ni", "Español (Nicaragua)"), # Nicaraguan Spanish + # ("es-ve", "Español (Venezuela)"), # Venezuelan Spanish + # ("et", "Eesti"), # Estonian + ("eu", "Euskara"), # Basque + ("fa", "فارسی‏"), # Persian + ("fi", "Suomi"), # Finnish + ("fr", "Français"), # French + # ("fy", "Frysk"), # Frisian + # ("ga", "Irish"), # Irish + # ("gl", "Galego"), # Galician + ("he", "עברית‏"), # Hebrew + # ("hi", "हिन्दी"), # Hindi + # ("hr", "Hrvatski"), # Croatian + # ("hu", "Magyar"), # Hungarian + # ("ia", "Interlingua"), # Interlingua + # ("id", "Bahasa Indonesia"), # Indonesian + # ("io", "IDO"), # Ido + # ("is", "Íslenska"), # Icelandic + ("it", "Italiano"), # Italian + ("ja", "日本語"), # Japanese + # ("ka", "ქართული"), # Georgian + # ("kk", "Қазақша"), # Kazakh + # ("km", "ភាសាខ្មែរ"), # Khmer + # ("kn", "ಕನ್ನಡ"), # Kannada + ("ko", "한국어"), # Korean + # ("lb", "Lëtzebuergesch"), # Luxembourgish + # ("lt", "Lietuvių"), # Lithuanian + ("lv", "Latviešu"), # Latvian + # ("mk", "Македонски"), # Macedonian + # ("ml", "മലയാളം"), # Malayalam + # ("mn", "Монгол"), # Mongolian + # ("mr", "मराठी"), # Marathi + # ("my", "မြန်မာ"), # Burmese + ("nb", "Norsk (bokmål)"), # Norwegian Bokmal + # ("ne", "नेपाली"), # Nepali + ("nl", "Nederlands"), # Dutch + # ("nn", "Norsk (nynorsk)"), # Norwegian Nynorsk + # ("os", "Ирон æвзаг"), # Ossetic + # ("pa", "ਪੰਜਾਬੀ"), # Punjabi + ("pl", "Polski"), # Polish + # ("pt", "Português (Portugal)"), # Portuguese + ("pt-br", "Português (Brasil)"), # Brazilian Portuguese + # ("ro", "Română"), # Romanian + ("ru", "Русский"), # Russian + # ("sk", "Slovenčina"), # Slovak + # ("sl", "Slovenščina"), # Slovenian + # ("sq", "Shqip"), # Albanian + ("sr", "Српски"), # Serbian + # ("sr-latn", "srpski"), # Serbian Latin + ("sv", "Svenska"), # Swedish + # ("sw", "Kiswahili"), # Swahili + # ("ta", "தமிழ்"), # Tamil + # ("te", "తెలుగు"), # Telugu + # ("th", "ภาษาไทย"), # Thai + ("tr", "Türkçe"), # Turkish + # ("tt", "татар теле"), # Tatar + # ("udm", "удмурт кыл"), # Udmurt + ("uk", "Українська"), # Ukrainian + # ("ur", "اردو‏"), # Urdu + ("vi", "Tiếng Việt"), # Vietnamese + ("zh-hans", "中文(简体)"), # Simplified Chinese + ("zh-hant", "中文(香港)"), # Traditional Chinese +] + +# Languages using BiDi (right-to-left) layout +LANGUAGES_BIDI = ["he", "ar", "fa", "ur"] + +LOCALE_PATHS = ( + os.path.join(BASE_DIR, "locale"), + os.path.join(BASE_DIR, "taiga", "locale"), +) + +SITES = { + "api": {"domain": "localhost:8000", "scheme": "http", "name": "api"}, + "front": {"domain": "localhost:9001", "scheme": "http", "name": "front"}, +} + +SITE_ID = "api" + +# Session and CSRF configuration +SESSION_ENGINE = "django.contrib.sessions.backends.db" +# SESSION_COOKIE_AGE = 1209600 # (2 weeks) and set SESSION_EXPIRE_AT_BROWSER_CLOSE to false +SESSION_EXPIRE_AT_BROWSER_CLOSE = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_AGE = None +CSRF_COOKIE_SECURE = True + +# MAIL OPTIONS +DEFAULT_FROM_EMAIL = "john@doe.com" +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +# 0 notifications will work in a synchronous way +# >0 an external process will check the pending notifications and will send them +# collapsed during that interval +CHANGE_NOTIFICATIONS_MIN_INTERVAL = 0 # seconds +SEND_BULK_EMAILS_WITH_CELERY = True + +DJMAIL_REAL_BACKEND = "django.core.mail.backends.console.EmailBackend" +DJMAIL_SEND_ASYNC = True +DJMAIL_MAX_RETRY_NUMBER = 3 +DJMAIL_TEMPLATE_EXTENSION = "jinja" + +# Events backend +EVENTS_PUSH_BACKEND = "taiga.events.backends.postgresql.EventsPushBackend" +# EVENTS_PUSH_BACKEND = "taiga.events.backends.rabbitmq.EventsPushBackend" +# EVENTS_PUSH_BACKEND_OPTIONS = {"url": "//guest:guest@127.0.0.1/"} + +# Message System +MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage" + +# The absolute url is mandatory because attachments +# urls depends on it. On production should be set +# something like https://media.taiga.io/ +MEDIA_URL = "http://localhost:8000/media/" +STATIC_URL = "http://localhost:8000/static/" + +# Static configuration. +MEDIA_ROOT = os.path.join(BASE_DIR, "media") +STATIC_ROOT = os.path.join(BASE_DIR, "static") + +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + +STATICFILES_DIRS = ( + # Put strings here, like "/home/html/static" or "C:/www/django/static". + # Don't forget to use absolute paths, not relative paths. +) + +# Default storage +DEFAULT_FILE_STORAGE = "taiga.base.storage.FileSystemStorage" + +FILE_UPLOAD_PERMISSIONS = 0o644 + +SECRET_KEY = "aw3+t2r(8(0kkrhg8)gx6i96v5^kv%6cfep9wxfom0%7dy0m9e" + +TEMPLATES = [ + { + "BACKEND": "django_jinja.backend.Jinja2", + "DIRS": [ + os.path.join(BASE_DIR, "templates"), + ], + "APP_DIRS": True, + "OPTIONS": { + 'context_processors': [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.request", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + ], + "match_extension": ".jinja", + } + }, + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + os.path.join(BASE_DIR, "templates"), + ], + "APP_DIRS": True, + "OPTIONS": { + 'context_processors': [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.request", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + ], + } + }, +] + + +MIDDLEWARE = [ + "taiga.base.middleware.cors.CorsMiddleware", + "taiga.events.middleware.SessionIDMiddleware", + + # Common middlewares + "django.middleware.common.CommonMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + + # Only needed by django admin + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", +] + + +ROOT_URLCONF = "taiga.urls" + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.admin", + "django.contrib.staticfiles", + "django.contrib.sitemaps", + "django.contrib.postgres", + + "taiga.base", + "taiga.base.api", + "taiga.locale", + "taiga.events", + "taiga.front", + "taiga.users", + "taiga.userstorage", + "taiga.auth.token_denylist", + "taiga.external_apps", + "taiga.projects", + "taiga.projects.references", + "taiga.projects.custom_attributes", + "taiga.projects.history", + "taiga.projects.notifications", + "taiga.projects.attachments", + "taiga.projects.likes", + "taiga.projects.votes", + "taiga.projects.milestones", + "taiga.projects.epics", + "taiga.projects.userstories", + "taiga.projects.tasks", + "taiga.projects.issues", + "taiga.projects.wiki", + "taiga.projects.contact", + "taiga.projects.settings", + "taiga.searches", + "taiga.timeline", + "taiga.mdrender", + "taiga.export_import", + "taiga.feedback", + "taiga.stats", + "taiga.hooks.github", + "taiga.hooks.gitlab", + "taiga.hooks.bitbucket", + "taiga.hooks.gogs", + "taiga.webhooks", + "taiga.importers", + + "djmail", + "django_jinja", + "django_jinja.contrib._humanize", + "sr", + "easy_thumbnails", + "raven.contrib.django.raven_compat", +] + +WSGI_APPLICATION = "taiga.wsgi.application" + +LOGGING = { + "version": 1, + "disable_existing_loggers": True, + "filters": { + "require_debug_false": { + "()": "django.utils.log.RequireDebugFalse" + } + }, + "formatters": { + "complete": { + "format": "%(levelname)s:%(asctime)s:%(module)s %(message)s" + }, + "simple": { + "format": "%(levelname)s:%(asctime)s: %(message)s" + }, + "null": { + "format": "%(message)s", + }, + "django.server": { + "()": "django.utils.log.ServerFormatter", + "format": "[%(server_time)s] %(message)s", + }, + }, + "handlers": { + "null": { + "level": "DEBUG", + "class": "logging.NullHandler", + }, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "simple", + }, + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "taiga.base.utils.logs.CustomAdminEmailHandler", + }, + "django.server": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "django.server", + }, + }, + "loggers": { + "django": { + "handlers": ["null"], + "level": "INFO", + "propagate": True, + }, + "django.request": { + "handlers": ["mail_admins", "console"], + "level": "ERROR", + "propagate": False, + }, + "django.server": { + "handlers": ["django.server"], + "level": "INFO", + "propagate": False, + }, + "MARKDOWN": { + "handlers": ["null"], + "level": "INFO", + "propagate": False, + }, + "taiga.export_import": { + "handlers": ["mail_admins", "console"], + "level": "ERROR", + "propagate": False, + }, + "taiga": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + } +} + + +AUTH_USER_MODEL = "users.User" + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=24), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=8), + 'CANCEL_TOKEN_LIFETIME': timedelta(days=100), +} + +FLUSH_REFRESHED_TOKENS_PERIODICITY = 3 * 24 * 3600 # seconds + +FORMAT_MODULE_PATH = "taiga.base.formats" + +DATE_INPUT_FORMATS = ( + "%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%b %d %Y", + "%b %d, %Y", "%d %b %Y", "%d %b, %Y", "%B %d %Y", + "%B %d, %Y", "%d %B %Y", "%d %B, %Y" +) + +# Authentication settings (only for django admin) +AUTHENTICATION_BACKENDS = ( + "django.contrib.auth.backends.ModelBackend", # default +) + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + # Mainly used by taiga-front + 'taiga.auth.authentication.JWTAuthentication', + + # Mainly used for api debug. + "taiga.auth.backends.Session", + + # Application tokens auth + "taiga.external_apps.auth_backends.Token", + ), + "DEFAULT_THROTTLE_CLASSES": ( + "taiga.base.throttling.CommonThrottle", + ), + "DEFAULT_THROTTLE_RATES": { + "anon-write": None, + "user-write": None, + "anon-read": None, + "user-read": None, + "import-mode": None, + "import-dump-mode": "1/minute", + "create-memberships": None, + "login-fail": None, + "register-success": None, + "user-detail": None, + "user-update": None, + }, + "DEFAULT_THROTTLE_WHITELIST": [], + "FILTER_BACKEND": "taiga.base.filters.FilterBackend", + "EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler", + "PAGINATE_BY": 30, + "PAGINATE_BY_PARAM": "page_size", + "MAX_PAGINATE_BY": 1000, + "DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z" +} + +# Extra expose header related to Taiga APP (see taiga.base.middleware.cors=) +APP_EXTRA_EXPOSE_HEADERS = [ + "taiga-info-total-opened-milestones", + "taiga-info-total-closed-milestones", + "taiga-info-backlog-total-userstories", + "taiga-info-userstories-without-swimlane", + "taiga-info-project-memberships", + "taiga-info-project-is-private", + "taiga-info-order-updated" +] + +DEFAULT_PROJECT_TEMPLATE = "scrum" +# Setting DEFAULT_PROJECT_SLUG_PREFIX to false removes the username from project slug +DEFAULT_PROJECT_SLUG_PREFIX = True +PUBLIC_REGISTER_ENABLED = False +# None or [] values in USER_EMAIL_ALLOWED_DOMAINS means allow any domain +USER_EMAIL_ALLOWED_DOMAINS = None + +PRIVATE_USER_PROFILES = False + +SEARCHES_MAX_RESULTS = 150 + +SOUTH_MIGRATION_MODULES = { + 'easy_thumbnails': 'easy_thumbnails.south_migrations', +} + + +THN_AVATAR_SIZE = 80 # 80x80 pixels +THN_AVATAR_BIG_SIZE = 300 # 300x300 pixels +THN_LOGO_SMALL_SIZE = 80 # 80x80 pixels +THN_LOGO_BIG_SIZE = 300 # 300x300 pixels +THN_TIMELINE_IMAGE_SIZE = 640 # 640x??? pixels +THN_CARD_IMAGE_WIDTH = 300 # 300 pixels +THN_CARD_IMAGE_HEIGHT = 200 # 200 pixels +THN_PREVIEW_IMAGE_WIDTH = 800 # 800 pixels + +THN_AVATAR_SMALL = "avatar" +THN_AVATAR_BIG = "big-avatar" +THN_LOGO_SMALL = "logo-small" +THN_LOGO_BIG = "logo-big" +THN_ATTACHMENT_TIMELINE = "timeline-image" +THN_ATTACHMENT_CARD = "card-image" +THN_ATTACHMENT_PREVIEW = "preview-image" + +THUMBNAIL_ALIASES = { + "": { + THN_AVATAR_SMALL: {"size": (THN_AVATAR_SIZE, THN_AVATAR_SIZE), "crop": True}, + THN_AVATAR_BIG: {"size": (THN_AVATAR_BIG_SIZE, THN_AVATAR_BIG_SIZE), "crop": True}, + THN_LOGO_SMALL: {"size": (THN_LOGO_SMALL_SIZE, THN_LOGO_SMALL_SIZE), "crop": True}, + THN_LOGO_BIG: {"size": (THN_LOGO_BIG_SIZE, THN_LOGO_BIG_SIZE), "crop": True}, + THN_ATTACHMENT_TIMELINE: {"size": (THN_TIMELINE_IMAGE_SIZE, 0), "crop": True}, + THN_ATTACHMENT_CARD: {"size": (THN_CARD_IMAGE_WIDTH, THN_CARD_IMAGE_HEIGHT), "crop": True}, + THN_ATTACHMENT_PREVIEW: {"size": (THN_PREVIEW_IMAGE_WIDTH, 0), "crop": False}, + }, +} + +# Feedback module settings +FEEDBACK_ENABLED = True +FEEDBACK_EMAIL = "support@taiga.io" + +# Stats module settings +STATS_ENABLED = False +STATS_CACHE_TIMEOUT = 60 * 60 # In second + +# List of functions called for filling correctly the ProjectModulesConfig associated to a project +# This functions should receive a Project parameter and return a dict with the desired configuration +PROJECT_MODULES_CONFIGURATORS = { + "github": "taiga.hooks.github.services.get_or_generate_config", + "gitlab": "taiga.hooks.gitlab.services.get_or_generate_config", + "bitbucket": "taiga.hooks.bitbucket.services.get_or_generate_config", + "gogs": "taiga.hooks.gogs.services.get_or_generate_config", +} + +# Official BitBucket valid IPs are https://confluence.atlassian.com/cloud/atlassian-cloud-ip-ranges-and-domains-744721662.html#AtlassiancloudIPrangesanddomains-OutgoingConnections +BITBUCKET_VALID_ORIGIN_IPS = [ + "13.52.5.96/28", + "13.236.8.224/28", + "18.184.99.224/28", + "18.234.32.224/28", + "18.246.31.224/28", + "52.215.192.224/28", + "104.192.137.240/28", + "104.192.138.240/28", + "104.192.140.240/28", + "104.192.142.240/28", + "104.192.143.240/28", + "185.166.143.240/28", + "185.166.142.240/28" +] + +GITLAB_VALID_ORIGIN_IPS = [] + +EXPORTS_TTL = 60 * 60 * 24 # 24 hours + +WEBHOOKS_ENABLED = False +WEBHOOKS_ALLOW_PRIVATE_ADDRESS = False +WEBHOOKS_ALLOW_REDIRECTS = False + +# If is True /front/sitemap.xml show a valid sitemap of taiga-front client +FRONT_SITEMAP_ENABLED = False +FRONT_SITEMAP_CACHE_TIMEOUT = 24 * 60 * 60 # In second +FRONT_SITEMAP_PAGE_SIZE = 25000 + + +EXTRA_BLOCKING_CODES = [] + +MAX_PRIVATE_PROJECTS_PER_USER = None # None == no limit +MAX_PUBLIC_PROJECTS_PER_USER = None # None == no limit +MAX_MEMBERSHIPS_PRIVATE_PROJECTS = None # None == no limit +MAX_MEMBERSHIPS_PUBLIC_PROJECTS = None # None == no limit + +MAX_PENDING_MEMBERSHIPS = 30 # Max number of unconfirmed memberships in a project + +# DJANGO SETTINGS RESOLVER +SR = { + "taigaio_url": "https://taiga.io", + "social": { + "twitter_url": "https://twitter.com/taigaio", + "github_url": "https://github.com/taigaio", + }, + "support": { + "url": "https://tree.taiga.io/support/", + "email": "support@taiga.io" + }, + "signature": "The Taiga Team", + "product_name": "Taiga", +} + +IMPORTERS = { + "github": { + "active": False, + "client_id": "", + "client_secret": "", + }, + "trello": { + "active": False, + "api_key": "", + "secret_key": "", + }, + "jira": { + "active": False, + "consumer_key": "", + "cert": "", + "pub_cert": "", + }, + "asana": { + "active": False, + "callback_url": "", + "app_id": "", + "app_secret": "", + } +} + +# Configuration for sending notifications +NOTIFICATIONS_CUSTOM_FILTER = False + +# MDRENDER +MDRENDER_CACHE_ENABLE = True +MDRENDER_CACHE_MIN_SIZE = 40 +MDRENDER_CACHE_TIMEOUT = 86400 + +# TELEMETRY + +ENABLE_TELEMETRY = True +RUDDER_WRITE_KEY = "1kmTTxJoSmaZNRpU1uORpyZ8mqv" +DATA_PLANE_URL = "https://telemetry.taiga.io/" +INSTALLED_APPS += [ + "taiga.telemetry" +] + +# NOTE: DON'T INSERT ANYTHING AFTER THIS BLOCK +TEST_RUNNER = "django.test.runner.DiscoverRunner" + +if "test" in sys.argv: + print("\033[1;91mNo django tests.\033[0m") + print("Try: \033[1;33mpy.test\033[0m") + sys.exit(0) +# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE diff --git a/settings/config.py.dev.example b/settings/config.py.dev.example new file mode 100644 index 000000000..343b43afc --- /dev/null +++ b/settings/config.py.dev.example @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +from .common import * # noqa, pylint: disable=unused-wildcard-import + +DEBUG = True + +TEMPLATES[0]["OPTIONS"]['context_processors'] += "django.template.context_processors.debug" + +ENABLE_TELEMETRY = False diff --git a/settings/config.py.prod.example b/settings/config.py.prod.example new file mode 100644 index 000000000..25bfd2245 --- /dev/null +++ b/settings/config.py.prod.example @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +import os + +from .common import * # noqa, pylint: disable=unused-wildcard-import + +######################################### +## GENERIC +######################################### + +DEBUG = False + +#ADMINS = ( +# ("Admin", "example@example.com"), +#) + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'taiga', + 'USER': 'taiga', + 'PASSWORD': 'changeme', + 'HOST': '', + 'PORT': '', + } +} + +SECRET_KEY = "changeme" + +TAIGA_SITES_SCHEME = "https" +TAIGA_SITES_DOMAIN = "example.com" +FORCE_SCRIPT_NAME = "" + +TAIGA_URL = f"{ TAIGA_SITES_SCHEME }://{ TAIGA_SITES_DOMAIN }{ FORCE_SCRIPT_NAME }" +SITES = { + "api": { "name": "api", "scheme": TAIGA_SITES_SCHEME, "domain": TAIGA_SITES_DOMAIN }, + "front": { "name": "front", "scheme": TAIGA_SITES_SCHEME, "domain": f"{ TAIGA_SITES_DOMAIN }{ FORCE_SCRIPT_NAME }" } +} + +# Setting DEFAULT_PROJECT_SLUG_PREFIX to false +# removes the username from project slug +DEFAULT_PROJECT_SLUG_PREFIX = False + +######################################### +## MEDIA AND STATIC +######################################### + +# MEDIA_ROOT = '/home/taiga/media' +MEDIA_URL = f"{ TAIGA_URL }/media/" +DEFAULT_FILE_STORAGE = "taiga_contrib_protected.storage.ProtectedFileSystemStorage" +THUMBNAIL_DEFAULT_STORAGE = DEFAULT_FILE_STORAGE + +# STATIC_ROOT = '/home/taiga/static' +STATIC_URL = f"{ TAIGA_URL }/static/" + +######################################### +## EMAIL +######################################### +# https://docs.djangoproject.com/en/3.1/topics/email/ +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +CHANGE_NOTIFICATIONS_MIN_INTERVAL = 120 # seconds + +DEFAULT_FROM_EMAIL = 'changeme@example.com' +EMAIL_USE_TLS = True +EMAIL_USE_SSL = True +EMAIL_HOST = 'localhost' +EMAIL_PORT = 587 +EMAIL_HOST_USER = 'user' +EMAIL_HOST_PASSWORD = 'password' + +######################################### +## EVENTS +######################################### +EVENTS_PUSH_BACKEND = "taiga.events.backends.rabbitmq.EventsPushBackend" +EVENTS_PUSH_BACKEND_OPTIONS = { + "url": "amqp://rabbitmquser:rabbitmqpassword@rabbitmqhost:5672/taiga" +} + + +######################################### +## TAIGA ASYNC +######################################### +CELERY_ENABLED = os.getenv('CELERY_ENABLED', 'True') == 'True' + +from kombu import Queue # noqa + +CELERY_BROKER_URL = "amqp://rabbitmquser:rabbitmqpassword@rabbitmq:5672/taiga" +CELERY_RESULT_BACKEND = None # for a general installation, we don't need to store the results +CELERY_ACCEPT_CONTENT = ['pickle', ] # Values are 'pickle', 'json', 'msgpack' and 'yaml' +CELERY_TASK_SERIALIZER = "pickle" +CELERY_RESULT_SERIALIZER = "pickle" +CELERY_TIMEZONE = 'Europe/Madrid' +CELERY_TASK_DEFAULT_QUEUE = 'tasks' +CELERY_QUEUES = ( + Queue('tasks', routing_key='task.#'), + Queue('transient', routing_key='transient.#', delivery_mode=1) +) +CELERY_TASK_DEFAULT_EXCHANGE = 'tasks' +CELERY_TASK_DEFAULT_EXCHANGE_TYPE = 'topic' +CELERY_TASK_DEFAULT_ROUTING_KEY = 'task.default' + + +######################################### +## CONTRIBS +######################################### +# INSTALLED_APPS += [ +# "taiga_contrib_slack", +# "taiga_contrib_github_auth", +# "taiga_contrib_gitlab_auth" +# ] +# +# GITHUB_API_CLIENT_ID = "changeme" +# GITHUB_API_CLIENT_SECRET = "changeme" +# +# GITLAB_API_CLIENT_ID = "changeme" +# GITLAB_API_CLIENT_SECRET = "changeme" +# GITLAB_URL = "changeme" + + +######################################### +## TELEMETRY +######################################### + +ENABLE_TELEMETRY = True + +######################################### +## REGISTRATION +######################################### + +PUBLIC_REGISTER_ENABLED = False + +######################################### +## THROTTLING +######################################### + +#REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { +# "anon-write": "20/min", +# "user-write": None, +# "anon-read": None, +# "user-read": None, +# "import-mode": None, +# "import-dump-mode": "1/minute", +# "create-memberships": None, +# "login-fail": None, +# "register-success": None, +# "user-detail": None, +# "user-update": None, +#} + +# This list should contain: +# - Taiga users IDs +# - Valid clients IP addresses (X-Forwarded-For header) +#REST_FRAMEWORK["DEFAULT_THROTTLE_WHITELIST"] = [] + +# LIMIT ALLOWED DOMAINS FOR REGISTER AND INVITE +# None or [] values in USER_EMAIL_ALLOWED_DOMAINS means allow any domain +#USER_EMAIL_ALLOWED_DOMAINS = None + +# PUBLIC OR PRIVATE NUMBER OF PROJECT PER USER +#MAX_PRIVATE_PROJECTS_PER_USER = None # None == no limit +#MAX_PUBLIC_PROJECTS_PER_USER = None # None == no limit +#MAX_MEMBERSHIPS_PRIVATE_PROJECTS = None # None == no limit +#MAX_MEMBERSHIPS_PUBLIC_PROJECTS = None # None == no limit + + +######################################### +## SITEMAP +######################################### + +# If is True /front/sitemap.xml show a valid sitemap of taiga-front client +#FRONT_SITEMAP_ENABLED = False +#FRONT_SITEMAP_CACHE_TIMEOUT = 24*60*60 # In second + + +######################################### +## FEEDBACK +######################################### + +# Note: See config in taiga-front too +#FEEDBACK_ENABLED = True +#FEEDBACK_EMAIL = "support@taiga.io" + + +######################################### +## STATS +######################################### + +#STATS_ENABLED = False +#STATS_CACHE_TIMEOUT = 60*60 # In second + + +######################################### +## IMPORTERS +######################################### + +# Configuration for the GitHub importer +# Remember to enable it in the front client too. +#IMPORTERS["github"] = { +# "active": True, +# "client_id": "XXXXXX_get_a_valid_client_id_from_github_XXXXXX", +# "client_secret": "XXXXXX_get_a_valid_client_secret_from_github_XXXXXX" +#} + +# Configuration for the Trello importer +# Remember to enable it in the front client too. +#IMPORTERS["trello"] = { +# "active": True, # Enable or disable the importer +# "api_key": "XXXXXX_get_a_valid_api_key_from_trello_XXXXXX", +# "secret_key": "XXXXXX_get_a_valid_secret_key_from_trello_XXXXXX" +#} + +# Configuration for the Jira importer +# Remember to enable it in the front client too. +#IMPORTERS["jira"] = { +# "active": True, # Enable or disable the importer +# "consumer_key": "XXXXXX_get_a_valid_consumer_key_from_jira_XXXXXX", +# "cert": "XXXXXX_get_a_valid_cert_from_jira_XXXXXX", +# "pub_cert": "XXXXXX_get_a_valid_pub_cert_from_jira_XXXXXX" +#} diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..01c9f04bb --- /dev/null +++ b/setup.cfg @@ -0,0 +1,14 @@ +[flake8] +ignore = E41,E266 +max-line-length = 120 +exclude = + .cache, + .git, + .tox, + .venv, + *__pycache__*, + *tests*, + *scripts*, + *migrations*, + *management* +max-complexity = 10 diff --git a/taiga-docker-main.zip b/taiga-docker-main.zip new file mode 100644 index 000000000..9f460dc64 Binary files /dev/null and b/taiga-docker-main.zip differ diff --git a/taiga-docker-main/.env b/taiga-docker-main/.env new file mode 100644 index 000000000..52c6cb8f7 --- /dev/null +++ b/taiga-docker-main/.env @@ -0,0 +1,36 @@ + +# Taiga's URLs - Variables to define where Taiga should be served +TAIGA_SCHEME=http # serve Taiga using "http" or "https" (secured) connection +TAIGA_DOMAIN=localhost:9000 # Taiga's base URL +SUBPATH="" # it'll be appended to the TAIGA_DOMAIN (use either "" or a "/subpath") +WEBSOCKETS_SCHEME=ws # events connection protocol (use either "ws" or "wss") + +# Taiga's Secret Key - Variable to provide cryptographic signing +SECRET_KEY="taiga-secret-key" # Please, change it to an unpredictable value!! + +# Taiga's Database settings - Variables to create the Taiga database and connect to it +POSTGRES_USER=taiga # user to connect to PostgreSQL +POSTGRES_PASSWORD=taiga # database user's password + +# Taiga's SMTP settings - Variables to send Taiga's emails to the users +EMAIL_BACKEND=console # use an SMTP server or display the emails in the console (either "smtp" or "console") +EMAIL_HOST=smtp.host.example.com # SMTP server address +EMAIL_PORT=587 # default SMTP port +EMAIL_HOST_USER=user # user to connect the SMTP server +EMAIL_HOST_PASSWORD=password # SMTP user's password +EMAIL_DEFAULT_FROM=changeme@example.com # default email address for the automated emails +# EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive (only set one of those to True) +EMAIL_USE_TLS=True # use TLS (secure) connection with the SMTP server +EMAIL_USE_SSL=False # use implicit TLS (secure) connection with the SMTP server + +# Taiga's RabbitMQ settings - Variables to leave messages for the realtime and asynchronous events +RABBITMQ_USER=taiga # user to connect to RabbitMQ +RABBITMQ_PASS=taiga # RabbitMQ user's password +RABBITMQ_VHOST=taiga # RabbitMQ container name +RABBITMQ_ERLANG_COOKIE=secret-erlang-cookie # unique value shared by any connected instance of RabbitMQ + +# Taiga's Attachments - Variable to define how long the attachments will be accesible +ATTACHMENTS_MAX_AGE=360 # token expiration date (in seconds) + +# Taiga's Telemetry - Variable to enable or disable the anonymous telemetry +ENABLE_TELEMETRY=True diff --git a/taiga-docker-main/.github/ISSUE_TEMPLATE/bug-report.md b/taiga-docker-main/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000..0230e26af --- /dev/null +++ b/taiga-docker-main/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,45 @@ +--- +name: "\U0001F41E Bug report" +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' +--- + + +**Describe the bug** + + +**How can we reproduce the behavior** + + +**Workarounds** + + +**Screenshots** + + +**Taiga environment** + + +**Desktop (please complete the following information):** + - **OS:** + - **Browser:** + - **Version:** + +**Additional context** + diff --git a/taiga-docker-main/.github/ISSUE_TEMPLATE/config.yml b/taiga-docker-main/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3d1cc00eb --- /dev/null +++ b/taiga-docker-main/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: ❓ Support Question + url: https://community.taiga.io/c/technical/8 + about: Questions and requests for support \ No newline at end of file diff --git a/taiga-docker-main/.github/ISSUE_TEMPLATE/feature-request.md b/taiga-docker-main/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 000000000..4a83235f9 --- /dev/null +++ b/taiga-docker-main/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,31 @@ +--- +name: "\U0001F680 Feature request" +about: Suggest an idea for this project +title: '[FR] ' +labels: feature_request +assignees: '' +--- +> **READ THIS FIRST!**: We recently announced Taiga plans for the future and they greatly affect how we manage this repository and the current Taiga 6 release. Check it [here](https://blog.taiga.io/announcing_taiganext.html).chore: readme announce taiga-next + + +**Please describe the problem / need you are trying to solve.** + + +**Describe the feature or the improvement you'd like and what are you trying to achieve.** + + +**Describe alternatives you've considered** + + +**Additional context** + diff --git a/taiga-docker-main/.github/stale.yml b/taiga-docker-main/.github/stale.yml new file mode 100644 index 000000000..a7d40e340 --- /dev/null +++ b/taiga-docker-main/.github/stale.yml @@ -0,0 +1,27 @@ +# 6 months of inactivity before an issue becomes stale +daysUntilStale: 180 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +# exemptLabels: +# - pinned +# - security +# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) +onlyLabels: + - bug + - question + - feature_request + - needs_info + - needs-info + - incomplete +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false + +only: issues diff --git a/taiga-docker-main/.gitignore b/taiga-docker-main/.gitignore new file mode 100644 index 000000000..93f5b982c --- /dev/null +++ b/taiga-docker-main/.gitignore @@ -0,0 +1,2 @@ +db-data +static-data diff --git a/taiga-docker-main/AUTHORS.rst b/taiga-docker-main/AUTHORS.rst new file mode 100644 index 000000000..3ac7b3c20 --- /dev/null +++ b/taiga-docker-main/AUTHORS.rst @@ -0,0 +1,27 @@ +Currently, PRIMARY AUTHORS are: + +- Carlos Goce +- Carlos Letamendia +- David Barragán Merino +- Daniel Herrero +- Juanfran Alcántara +- Miguel González +- Miryam González +- Natacha Menjibar +- Pablo Ruiz +- Ramiro Sánchez +- Teresa de la Torre +- Xavi Julian +- Yamila Moreno + +Other previous core team members: + +- Alejandro Alonso +- Alex Hermida +- Andrey Antukh +- Anler Hernández +- Daniel García +- Esther Moreno +- Jesus Espino Garcia + +Special thanks to `Kaleidos Open Source S.L. `_ for providing time for Taiga development. diff --git a/taiga-docker-main/CHANGELOG.md b/taiga-docker-main/CHANGELOG.md new file mode 100644 index 000000000..5a4d4ee7b --- /dev/null +++ b/taiga-docker-main/CHANGELOG.md @@ -0,0 +1,88 @@ +# Changelog + +## 6.8.1 (Unreleased) + +- ... + +## 6.8.0 (2024-04-03) + +- Compatible with Taiga 6.8.0 + +## 6.7.2 (2024-02-12) + +- Add some missed settings (email service and instance domain) to docker-compose-inits.yml + +## 6.7.1 (2023-08-08) + +- Fixes high CPU peaks for rabbitmq services + +## 6.7.0 (2023-06-12) + +- Compatible with Taiga 6.7.0 + +## 6.6.0 (2023-03-06) + +- New .env based configuration docker +- Control services startup based on healthchecks +- Thanks to @tibroc for adding hostnames to rabbitmq services +- Thanks to @ralfyang for updating shell scripts to use latest docker version + +## 6.5.0 (2022-01-24) + +- Compatible with Taiga 6.5.0 + +## 6.4.0 (2021-09-06) + +- Serve Taiga in subpath + +## 6.3.0 (2021-08-10) + +- Temporary fix specifying latest valid image of RabbitMQ (issue #tg-4700) + +## 6.2.2 (2021-07-15) + +- Compatible with Taiga 6.2.2 + +## 6.2.1 (2021-06-22) + +- Compatible with Taiga 6.2.1 + +## 6.2.0 (2021-06-09) + +- Compatible with Taiga 6.2.0 + +## 6.1.1 (2021-05-18) + +- Compatible with Taiga 6.1.1 + +## 6.1.0 (2021-05-04) + +- Update github templates + +## 6.0.4 (2021-04-06) + +- Add named volumes for rabbit services +- Improve docker configuration with `POSTGRES_PORT`. + +## 6.0.3 (2021-02-22) + +- Improve docker configuration with `EVENTS_PUSH_BACKEND_URL` and `CELERY_BROKER_URL` variables. Thanks to @ginuerzh. +- Simplify and improve docker configuration + +## 6.0.2 (2021-02-15) + +Add `ENABLE_SLACK`, `ENABLE_GITHUB_AUTH` and `ENABLE_GITLAB_AUTH` environment variables + + +## 6.0.1 (2021-02-08) + +Adatp to latest + + +## 6.0.0 (2021-02-02) + +### Features + +- main `docker-compose.yml` for Taiga installation + +- `docker-compose-inits.yml` to run `python manage.py` with docker-compose diff --git a/taiga-docker-main/CONTRIBUTING.md b/taiga-docker-main/CONTRIBUTING.md new file mode 100644 index 000000000..34a97dcc6 --- /dev/null +++ b/taiga-docker-main/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# How can I contribute to Taiga? + +Thank you for your interest in contributing to Taiga. As a contributor, here are the guidelines we would like you to follow. For more information check out our [Taiga community page](https://community.taiga.io/t/how-can-i-contribute/159). + +## Code of Conduct + +Help us keep the Taiga Community open and inclusive. Please read and follow our [Code of Conduct](https://www.taiga.io/code-of-conduct). + +## Code patches, enhacements + +There are many different ways to contribute to Taiga's platform, from patches, to documentation and enhancements, just find the one that best fits with your skills. Check out our detailed [contribution guide](https://community.taiga.io/t/how-can-i-contribute/159#code-patches-enhacements-3). + +## Issues + +If you find a bug in the source code, you can help us by [submitting an issue](https://github.com/taigaio/taiga-docker/issues/new/choose). Even better, you can submit a Pull Request with a fix. + +## Commit Message Guidelines + +Taiga follows the [conventional commits format](https://www.conventionalcommits.org/en/v1.0.0/). + +## Developer Certificate of Origin + License + +By contributing to Kaleidos INC, You accept and agree to the following terms and conditions for Your present and future Contributions submitted to Kaleidos INC Except for the license granted herein to Kaleidos INC and recipients of software distributed by Kaleidos INC, You reserve all right, title, and interest in and to Your Contributions. + +All Contributions are subject to the following DCO + License terms. + +[DCO + License](DCOLICENSE) + +## Translation + +Access our team of translators with the link below, set up an account in Transifex and start contributing. Join us to make sure your language is covered! [Help Taiga to translate content](https://hosted.weblate.org/projects/taiga/) + +Localization Bugs: Taiga use Weblate to manage the i18n files so don’t submit a pull request to those files (except -en.json). To fix a translation, just access our team of translators, set up an account in the Taiga Weblate project and start contributing. diff --git a/taiga-docker-main/DCOLICENSE b/taiga-docker-main/DCOLICENSE new file mode 100644 index 000000000..2f160ef93 --- /dev/null +++ b/taiga-docker-main/DCOLICENSE @@ -0,0 +1,399 @@ +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I have the right +to submit it under the open source license indicated in the file; or + +(b) The contribution is based upon previous work that, to the best of my +knowledge, is covered under an appropriate open source license and I have the +right under that license to submit that work with modifications, whether +created in whole or in part by me, under the same open source license +(unless I am permitted to submit under a different license), as indicated +in the file; or + +(c) The contribution was provided directly to me by some other person who +certified (a), (b) or (c) and I have not modified it. + +(d) I understand and agree that this project and the contribution are public +and that a record of the contribution (including all personal information I +submit with it, including my sign-off) is maintained indefinitely and may be +redistributed consistent with this project or the open source license(s) +involved. + +All Contributions to this project are licensed under the following license: + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/taiga-docker-main/LICENSE b/taiga-docker-main/LICENSE new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/taiga-docker-main/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/taiga-docker-main/README.md b/taiga-docker-main/README.md new file mode 100644 index 000000000..31e7465b4 --- /dev/null +++ b/taiga-docker-main/README.md @@ -0,0 +1,530 @@ +# Taiga Docker + +| :information_source: | If you're already using taiga-docker, follow this [migration guide](https://docs.taiga.io/upgrades-docker-migrate.html) to use the new `.env` based deployment. | +|---------------|:----| + +> **Note:** +> You can access the [older docker installation guide](https://docs.taiga.io/setup-production.old.html#setup-prod-with-docker-old) for documentation purposes, intended just for earlier versions of Taiga (prior to ver. 6.6.0) + + +## Getting Started + +This section intends to explain how to get Taiga up and running in a simple two steps, using **docker** and **docker compose**. + +If you don't have docker installed, please follow installation instructions from [docker.com](https://docs.docker.com/engine/install/) (**version 19.03.0+**) + +Additionally, it's necessary to have familiarity with Docker, docker compose and Docker repositories. + +> **Note** +> branch `stable` should be used to deploy Taiga in production and `main` branch for development purposes. + +### Start the application + +```sh +$ ./launch-taiga.sh +``` + +After some instants, when the application is started you can proceed to create the superuser with the following script: + +```sh +$ ./taiga-manage.sh createsuperuser +``` + +The `taiga-manage.sh` script lets launch manage.py commands on the +back instance: + +```sh +$ ./taiga-manage.sh [COMMAND] +``` + +If you're testing it in your own machine, you can access the application in **http://localhost:9000**. If you're deploying in a server, you'll need to configure hosts and nginx as described later. + +![Taiga screenshot](imgs/taiga.jpg) + +## Documentation + +Currently, we have authored three main documentation hubs: + +- **[API](https://docs.taiga.io/api.html)**: Our API documentation and reference for developing from Taiga API. +- **[Documentation](https://docs.taiga.io/)**: If you need to install Taiga on your own server, this is the place to find some guides. +- **[Taiga Community](https://community.taiga.io/)**: This page is intended to be the support reference page for the users. + +## Bug reports + +If you **find a bug** in Taiga you can always report it: + +- in [Taiga issues](https://tree.taiga.io/project/taiga/issues). **This is the preferred way** +- in [Github issues](https://github.com/taigaio/taiga-docker/issues) +- send us a mail to support@taiga.io if is a bug related to [tree.taiga.io](https://tree.taiga.io) +- send us a mail to security@taiga.io if is a **security bug** + +One of our fellow Taiga developers will search, find and hunt it as soon as possible. + +Please, before reporting a bug, write down how can we reproduce it, your operating system, your browser and version, and if it's possible, a screenshot. Sometimes it takes less time to fix a bug if the developer knows how to find it. + +## Community + +If you **need help to setup Taiga**, want to **talk about some cool enhancemnt** or you have **some questions**, please go to [Taiga community](https://community.taiga.io/). + +If you want to be up to date about announcements of releases, important changes and so on, you can subscribe to our newsletter (you will find it by scrolling down at [https://taiga.io](https://www.taiga.io/)) and follow [@taigaio](https://twitter.com/taigaio) on Twitter. + +## Contribute to Taiga + +There are many different ways to contribute to Taiga's platform, from patches, to documentation and UI enhancements, just find the one that best fits with your skills. Check out our detailed [contribution guide](https://community.taiga.io/t/how-can-i-contribute/159#code-patches-enhacements-3). + +## Code of Conduct + +Help us keep the Taiga Community open and inclusive. Please read and follow our [Code of Conduct](https://github.com/taigaio/code-of-conduct/blob/main/CODE_OF_CONDUCT.md). + +## License + +Every code patch accepted in Taiga codebase is licensed under [MPL 2.0](LICENSE). You must be careful to not include any code that can not be licensed under this license. + +Please read carefully [our license](LICENSE) and ask us if you have any questions as well as the [Contribution policy](https://github.com/taigaio/taiga-docker/blob/main/CONTRIBUTING.md). + +## Configuration + +We've exposed the **Basic configuration** settings in Taiga to an `.env` file. We strongly recommend you to change it, or at least review its content, to avoid using the default values. + +Both `docker-compose.yml` and `docker-compose-inits.yml` will read from this file to populate their environment variables, so, initially you don't need to change them. Edit these files just in case you require to enable **Additional customization**, or an **Advanced configuration**. + +Refer to these sections for further information. + +## Basic Configuration + +You will find basic **configuration variables** in the `.env` file. As stated before, we encourage you to edit these values, especially those affecting the security. + +### Database settings + +These vars are used to create the database for Taiga and connect to it. + +```bash +POSTGRES_USER=taiga # user to connect to PostgreSQL +POSTGRES_PASSWORD=taiga # database user's password +``` + +### URLs settings + +These vars set where your Taiga instance should be served, and the security protocols to use in the communication layer. + +```bash +TAIGA_SCHEME=http # serve Taiga using "http" or "https" (secured) connection +TAIGA_DOMAIN=localhost:9000 # Taiga's base URL +SUBPATH="" # it'll be appended to the TAIGA_DOMAIN (use either "" or a "/subpath") +WEBSOCKETS_SCHEME=ws # events connection protocol (use either "ws" or "wss") +``` + +The default configuration assumes Taiga is being served in a **subdomain**. For example: + +```bash +TAIGA_SCHEME=https +TAIGA_DOMAIN=taiga.mycompany.com +SUBPATH="" +WEBSOCKETS_SCHEME=wss +``` + +If Taiga is being served in a **subpath**, instead of a subdomain, the configuration should be something like this: + +```bash +TAIGA_SCHEME=https +TAIGA_DOMAIN=mycompany.com +SUBPATH="/taiga" +WEBSOCKETS_SCHEME=wss +``` + +### Secret Key settings + +This variable allows you to set the secret key in Taiga, used in the cryptographic signing. + +```bash +SECRET_KEY="taiga-secret-key" # Please, change it to an unpredictable value! +``` + +### Email Settings + +By default, emails will be printed in the standard output (`EMAIL_BACKEND=console`). If you have your own SMTP service, change it to `EMAIL_BACKEND=smtp` and configure the rest of these variables with the values supplied by your SMTP provider: + +```bash +EMAIL_BACKEND=console # use an SMTP server or display the emails in the console (either "smtp" or "console") +EMAIL_HOST=smtp.host.example.com # SMTP server address +EMAIL_PORT=587 # default SMTP port +EMAIL_HOST_USER=user # user to connect the SMTP server +EMAIL_HOST_PASSWORD=password # SMTP user's password +EMAIL_DEFAULT_FROM=changeme@example.com # email address for the automated emails + +# EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive (only set one of those to True) +EMAIL_USE_TLS=True # use TLS (secure) connection with the SMTP server +EMAIL_USE_SSL=False # use implicit TLS (secure) connection with the SMTP server +``` + +### Queue manager settings + +These variables are used to leave messages in the rabbitmq services. + +```bash +RABBITMQ_USER=taiga # user to connect to RabbitMQ +RABBITMQ_PASS=taiga # RabbitMQ user's password +RABBITMQ_VHOST=taiga # RabbitMQ container name +RABBITMQ_ERLANG_COOKIE=secret-erlang-cookie # unique value shared by any connected instance of RabbitMQ +``` + +### Attachments settings + +You can configure how long the attachments will be accessible by changing the token expiration timer. After that amount of seconds the token will expire, but you can always get a new attachment url with an active token. + +```bash +ATTACHMENTS_MAX_AGE=360 # token expiration date (in seconds) +``` + +### Telemetry Settings + +Telemetry anonymous data is collected in order to learn about the use of Taiga and improve the platform based on real scenarios. You may want to enable this to help us shape future Taiga. + +```bash +ENABLE_TELEMETRY=True +``` + +You can opt out by setting this variable to False. By default, it's True. + +## Additional customization + +All these customization options are by default disabled and require you to edit `docker-compose.yml`. + +You should add the corresponding environment variables in the proper services (or in `&default-back-environment` group) with a valid value in order to enable them. Please, do not modify it unless you know what you’re doing. + +### Session cookies in Django Admin + +Taiga doesn't use session cookies in its API as it stateless. However, the Django Admin (`/admin/`) uses session cookie for authentication. By default, Taiga is configured to work behind HTTPS. If you're using HTTP (despite the strong recommendations against it), you'll need to configure the following environment variables so you can access the Admin: + +Add to `&default-back-environment` environments +```yml +SESSION_COOKIE_SECURE: "False" +CSRF_COOKIE_SECURE: "False" +``` + +More info about those variables can be found [here](https://docs.djangoproject.com/en/3.1/ref/settings/#csrf-cookie-secure). + +### Public registration + +Public registration is disabled by default. If you want to allow a public register, you have to enable public registration on both, frontend and backend. + +> **Note** +> Be careful with the upper and lower case in these settiings. We will use 'True' for the backend and 'true' for the frontend (this is not a typo, otherwise it won't work). + + +Add to `&default-back-environment` environments +```yml +PUBLIC_REGISTER_ENABLED: "True" +``` + +Add to `taiga-front` service environments +```yml +PUBLIC_REGISTER_ENABLED: "true" +``` + +> **Important**: +> +> Taiga (in its default configuration) disables both Gitlab or Github oauth buttons whenever the public registration option hasn't been activated. To be able to use Github/Gitlab login/registration, make sure you have public registration activated on your Taiga instance. + +### GitHub OAuth login + +Used for login with Github. This feature is disabled by default. + +Follow the documentation ([GitHub - Creating an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)) in Github, when save application Github displays the ID and Secret. + +> **Note** +> Be careful with the upper and lower case in these settiings. We will use 'True' for the backend and 'true' for the frontend (this is not a typo, otherwise it won't work). + +> **Note** +> `GITHUB_API_CLIENT_ID / GITHUB_CLIENT_ID` should have the same value. + + +Add to `&default-back-environment` environments +```yml +ENABLE_GITHUB_AUTH: "True" +GITHUB_API_CLIENT_ID: "github-client-id" +GITHUB_API_CLIENT_SECRET: "github-client-secret" +PUBLIC_REGISTER_ENABLED: "True" +``` + +Add to `taiga-front` service environments +```yml +ENABLE_GITHUB_AUTH: "true" +GITHUB_CLIENT_ID: "github-client-id" +PUBLIC_REGISTER_ENABLED: "true" +```` + +### Gitlab OAuth login + +Used for login with GitLab. This feature is disabled by default. + +Follow the documentation ([Configure GitLab as an OAuth 2.0 authentication identity provider](https://docs.gitlab.com/ee/integration/oauth_provider.html)) in Gitlab to get the _gitlab-client-id_ and the _gitlab-client-secret_. + +> **Note** +> Be careful with the upper and lower case in these settiings. We will use 'True' for the backend and 'true' for the frontend (this is not a typo, otherwise it won't work). + +> **Note** +> `GITLAB_API_CLIENT_ID / GITLAB_CLIENT_ID` and `GITLAB_URL` should have the same value. + +Add to `&default-back-environment` environments +```yml +ENABLE_GITLAB_AUTH: "True" +GITLAB_API_CLIENT_ID: "gitlab-client-id" +GITLAB_API_CLIENT_SECRET: "gitlab-client-secret" +GITLAB_URL: "gitlab-url" +PUBLIC_REGISTER_ENABLED: "True" +``` + +Add to `taiga-front` service environments +```yml +ENABLE_GITLAB_AUTH: "true" +GITLAB_CLIENT_ID: "gitlab-client-id" +GITLAB_URL: "gitlab-url" +PUBLIC_REGISTER_ENABLED: "true" +``` + +### Slack integration + +Enable Slack integration in your Taiga instance. This feature is disabled by default. + +> **Note** +> Be careful with the upper and lower case in these settiings. We will use 'True' for the backend and 'true' for the frontend (this is not a typo, otherwise it won't work). + +Add to `&default-back-environment` environments +```yml +ENABLE_SLACK: "True" +``` + +Add to `taiga-front` service environments +```yml +ENABLE_SLACK: "true" +``` + +### GitHub importer + +Activating this feature, you will be able to import projects from GitHub. + +Follow this documentation ([GitHub - Creating an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)) to obtain the _client id_ and the _client secret_ from GitHun. + +> **Note** +> Be careful with the upper and lower case in these settiings. We will use 'True' for the backend and 'true' for the frontend (this is not a typo, otherwise it won't work). + +Add to `&default-back-environment` environments +```yml +ENABLE_GITHUB_IMPORTER: "True" +GITHUB_IMPORTER_CLIENT_ID: "client-id-from-github" +GITHUB_IMPORTER_CLIENT_SECRET: "client-secret-from-github" +``` + +Add to `taiga-front` service environments +```yml +ENABLE_GITHUB_IMPORTER: "true" +``` + +### Jira Importer + +Activating this feature, you will be able to import projects from Jira. + +Follow this documentation ([Jira - OAuth 1.0a for REST APIs](https://developer.atlassian.com/cloud/jira/platform/jira-rest-api-oauth-authentication/)) to obtain the _consumer key_ and the _public/private certificate key_. + + +> **Note** +> Be careful with the upper and lower case in these settiings. We will use 'True' for the backend and 'true' for the frontend (this is not a typo, otherwise it won't work). + +Add to `&default-back-environment` environments +```yml +ENABLE_JIRA_IMPORTER: "True" +JIRA_IMPORTER_CONSUMER_KEY: "consumer-key-from-jira" +JIRA_IMPORTER_CERT: "cert-from-jira" +JIRA_IMPORTER_PUB_CERT: "pub-cert-from-jira" +``` + +Add to `taiga-front` service environments +```yml +ENABLE_JIRA_IMPORTER: "true" +``` + +### Trello importer + +Activating this feature, you will be able to import projects from Trello. + +For configure Trello, you have two options: +- go to [https://trello.com/app-key](https://trello.com/app-key) (you must login first) and obtaing your development _API key_ and your _secret key_. +- or with the new method, [create a new Power-Up](https://developer.atlassian.com/cloud/trello/guides/rest-api/api-introduction/#managing-your-api-key) and generate an _API key_ and a _secret key_ + +> **Note** +> Be careful with the upper and lower case in these settiings. We will use 'True' for the backend and 'true' for the frontend (this is not a typo, otherwise it won't work). + +Add to `&default-back-environment` environments +```yml +ENABLE_TRELLO_IMPORTER: "True" +TRELLO_IMPORTER_API_KEY: "api-key-from-trello" +TRELLO_IMPORTER_SECRET_KEY: "secret-key-from-trello" +``` + +Add to `taiga-front` service environments +```yml +ENABLE_TRELLO_IMPORTER: "true" +``` + +## Advanced configuration + +The advanced configuration **will ignore** the environment variables in `docker-compose.yml` or `docker-compose-inits.yml`. Skip this section if you're using env vars. + +It requires you to map the configuration files of `taiga-back` and `taiga-front` services to local files in order to unlock further configuration options. + +**Map a `config.py` file** + +From [taiga-back](https://github.com/taigaio/taiga-back) download the file `settings/config.py.prod.example` and rename it: + +```bash +mv settings/config.py.prod.example settings/config.py +``` + +Edit `config.py` with your own configuration: + +- Taiga secret key: **it's important** to change it. It must have the same value as the secret key in `taiga-events` and `taiga-protected` +- Taiga urls: configure where Taiga would be served using `TAIGA_URL`, `SITES` and `FORCE_SCRIPT_NAME` (see examples below) +- Connection to PostgreSQL; check `DATABASES` section in the file +- Connection to RabbitMQ for `taiga-events`; check "EVENTS" section in the file +- Connection to RabbitMQ for `taiga-async`; check "TAIGA ASYNC" section in the file +- Credentials for email; check "EMAIL" section in the file +- Enable/disable anonymous telemetry; check "TELEMETRY" section in the file + +Example to configure Taiga in **subdomain**: +```python +TAIGA_SITES_SCHEME = "https" +TAIGA_SITES_DOMAIN = "taiga.mycompany.com" +FORCE_SCRIPT_NAME = "" +``` + +Example to configure Taiga in **subpath**: +```python +TAIGA_SITES_SCHEME = "https" +TAIGA_SITES_DOMAIN = "taiga.mycompany.com" +FORCE_SCRIPT_NAME = "/taiga" +``` + +Check as well the rest of the configuration if you need to enable some advanced features. + +Map the file into `/taiga-back/settings/config.py`. Have in mind that you have to map it both in `docker-compose.yml` and `docker-compose-inits.yml`. You can check the `x-volumes` section in docker-compose.yml with an example. + +**Map a `conf.json` file** + +From [taiga-front](https://github.com/taigaio/taiga-front) download the file `dist/conf.example.json` and rename it: + +```bash +mv dist/conf.example.json dist/conf.json +``` + +Edit it with your own configuration: + +- Taiga urls: configure where Taiga would be served using `api`, `eventsUrl` and `baseHref` (see examples below) + +Example of `conf.json` to serve Taiga in a **subdomain**: +```json +{ + "api": "https://taiga.mycompany.com/api/v1/", + "eventsUrl": "wss://taiga.mycompany.com/events", + "baseHref": "/", +``` + +Example of `conf.json` to serve Taiga in **subpath**: +```json +{ + "api": "https://mycompany.com/taiga/api/v1/", + "eventsUrl": "wss://mycompany.com/taiga/events", + "baseHref": "/taiga/", +``` + +Check as well the rest of the configuration if you need to enable some advanced features. + +Map the file into `/taiga-front/dist/config.py`. + +## Configure an admin user + +```bash +$ docker compose up -d + +$ docker compose -f docker-compose.yml -f docker-compose-inits.yml run --rm taiga-manage createsuperuser +``` + +## Up and running + +Once everything has been installed, launch all the services and check the result: + +```bash +$ docker compose up -d +``` + +## Configure the proxy + +Your host configuration needs to make a proxy to `http://localhost:9000`. + +If Taiga is being served in a **subdomain**: +``` + server { + server_name taiga.mycompany.com; + + location / { + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_redirect off; + proxy_pass http://localhost:9000/; + } + + # Events + location /events { + proxy_pass http://localhost:9000/events; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # TLS: Configure your TLS following the best practices inside your company + # Logs and other configurations + } +``` + +If Taiga is being served in a **subpath** instead of a subdomain, the configuration should be something like: +``` +server { + server_name mycompany.com; + + location /taiga/ { + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_redirect off; + proxy_pass http://localhost:9000/; + } + + # Events + location /taiga/events { + proxy_pass http://localhost:9000/events; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # TLS: Configure your TLS following the best practices inside your company + # Logs and other configurations +} +``` + +## Change between subpath and subdomain + +If you're changing Taiga configuration from default subdomain (https://taiga.mycompany.com) to subpath (http://mycompany.com/subpath) or vice versa, on top of adjusting the configuration as said above, you should consider changing the TAIGA_SECRET_KEY so the refresh works properly for the end user. diff --git a/taiga-docker-main/VERSION.md b/taiga-docker-main/VERSION.md new file mode 100644 index 000000000..e029aa99b --- /dev/null +++ b/taiga-docker-main/VERSION.md @@ -0,0 +1 @@ +6.8.0 diff --git a/taiga-docker-main/docker-compose-inits.yml b/taiga-docker-main/docker-compose-inits.yml new file mode 100644 index 000000000..886aa8379 --- /dev/null +++ b/taiga-docker-main/docker-compose-inits.yml @@ -0,0 +1,42 @@ +version: "3.5" + +x-environment: + &default-back-environment + POSTGRES_DB: "taiga" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + POSTGRES_HOST: "taiga-db" + + TAIGA_SECRET_KEY: "${SECRET_KEY}" + TAIGA_SITES_SCHEME: "${TAIGA_SCHEME}" + TAIGA_SITES_DOMAIN: "${TAIGA_DOMAIN}" + + EMAIL_BACKEND: "django.core.mail.backends.${EMAIL_BACKEND}.EmailBackend" + DEFAULT_FROM_EMAIL: "${EMAIL_DEFAULT_FROM}" + EMAIL_USE_TLS: "${EMAIL_USE_TLS}" + EMAIL_USE_SSL: "${EMAIL_USE_SSL}" + EMAIL_HOST: "${EMAIL_HOST}" + EMAIL_PORT: "${EMAIL_PORT}" + EMAIL_HOST_USER: "${EMAIL_HOST_USER}" + EMAIL_HOST_PASSWORD: "${EMAIL_HOST_PASSWORD}" + + RABBITMQ_USER: "${RABBITMQ_USER}" + RABBITMQ_PASS: "${RABBITMQ_PASS}" + CELERY_ENABLED: "False" + +x-volumes: + &default-back-volumes + - taiga-static-data:/taiga-back/static + - taiga-media-data:/taiga-back/media + # - ./config.py:/taiga-back/settings/config.py + +services: + taiga-manage: + image: taigaio/taiga-back:latest + environment: *default-back-environment + depends_on: + - taiga-db + entrypoint: "python manage.py" + volumes: *default-back-volumes + networks: + - taiga diff --git a/taiga-docker-main/docker-compose.yml b/taiga-docker-main/docker-compose.yml new file mode 100644 index 000000000..2f803fc4d --- /dev/null +++ b/taiga-docker-main/docker-compose.yml @@ -0,0 +1,167 @@ +version: "3.5" + +x-environment: + &default-back-environment + # These environment variables will be used by taiga-back and taiga-async. + # Database settings + POSTGRES_DB: "taiga" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + POSTGRES_HOST: "taiga-db" + # Taiga settings + TAIGA_SECRET_KEY: "${SECRET_KEY}" + TAIGA_SITES_SCHEME: "${TAIGA_SCHEME}" + TAIGA_SITES_DOMAIN: "${TAIGA_DOMAIN}" + TAIGA_SUBPATH: "${SUBPATH}" + # Email settings. + EMAIL_BACKEND: "django.core.mail.backends.${EMAIL_BACKEND}.EmailBackend" + DEFAULT_FROM_EMAIL: "${EMAIL_DEFAULT_FROM}" + EMAIL_USE_TLS: "${EMAIL_USE_TLS}" + EMAIL_USE_SSL: "${EMAIL_USE_SSL}" + EMAIL_HOST: "${EMAIL_HOST}" + EMAIL_PORT: "${EMAIL_PORT}" + EMAIL_HOST_USER: "${EMAIL_HOST_USER}" + EMAIL_HOST_PASSWORD: "${EMAIL_HOST_PASSWORD}" + # Rabbitmq settings + RABBITMQ_USER: "${RABBITMQ_USER}" + RABBITMQ_PASS: "${RABBITMQ_PASS}" + # Telemetry settings + ENABLE_TELEMETRY: "${ENABLE_TELEMETRY}" + # ...your customizations go here + +x-volumes: + &default-back-volumes + # These volumens will be used by taiga-back and taiga-async. + - taiga-static-data:/taiga-back/static + - taiga-media-data:/taiga-back/media + # - ./config.py:/taiga-back/settings/config.py + +services: + taiga-db: + image: postgres:12.3 + environment: + POSTGRES_DB: "taiga" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 2s + timeout: 15s + retries: 5 + start_period: 3s + volumes: + - taiga-db-data:/var/lib/postgresql/data + networks: + - taiga + + taiga-back: + image: taigaio/taiga-back:latest + environment: *default-back-environment + volumes: *default-back-volumes + networks: + - taiga + depends_on: + taiga-db: + condition: service_healthy + taiga-events-rabbitmq: + condition: service_started + taiga-async-rabbitmq: + condition: service_started + + taiga-async: + image: taigaio/taiga-back:latest + entrypoint: ["/taiga-back/docker/async_entrypoint.sh"] + environment: *default-back-environment + volumes: *default-back-volumes + networks: + - taiga + depends_on: + taiga-db: + condition: service_healthy + taiga-events-rabbitmq: + condition: service_started + taiga-async-rabbitmq: + condition: service_started + + taiga-async-rabbitmq: + image: rabbitmq:3.8-management-alpine + environment: + RABBITMQ_ERLANG_COOKIE: "${RABBITMQ_ERLANG_COOKIE}" + RABBITMQ_DEFAULT_USER: "${RABBITMQ_USER}" + RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASS}" + RABBITMQ_DEFAULT_VHOST: "${RABBITMQ_VHOST}" + hostname: "taiga-async-rabbitmq" + volumes: + - taiga-async-rabbitmq-data:/var/lib/rabbitmq + networks: + - taiga + + taiga-front: + image: taigaio/taiga-front:latest + environment: + TAIGA_URL: "${TAIGA_SCHEME}://${TAIGA_DOMAIN}" + TAIGA_WEBSOCKETS_URL: "${WEBSOCKETS_SCHEME}://${TAIGA_DOMAIN}" + TAIGA_SUBPATH: "${SUBPATH}" + # ...your customizations go here + networks: + - taiga + # volumes: + # - ./conf.json:/usr/share/nginx/html/conf.json + + taiga-events: + image: taigaio/taiga-events:latest + environment: + RABBITMQ_USER: "${RABBITMQ_USER}" + RABBITMQ_PASS: "${RABBITMQ_PASS}" + TAIGA_SECRET_KEY: "${SECRET_KEY}" + networks: + - taiga + depends_on: + taiga-events-rabbitmq: + condition: service_started + + taiga-events-rabbitmq: + image: rabbitmq:3.8-management-alpine + environment: + RABBITMQ_ERLANG_COOKIE: "${RABBITMQ_ERLANG_COOKIE}" + RABBITMQ_DEFAULT_USER: "${RABBITMQ_USER}" + RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASS}" + RABBITMQ_DEFAULT_VHOST: "${RABBITMQ_VHOST}" + hostname: "taiga-events-rabbitmq" + volumes: + - taiga-events-rabbitmq-data:/var/lib/rabbitmq + networks: + - taiga + + taiga-protected: + image: taigaio/taiga-protected:latest + environment: + MAX_AGE: "${ATTACHMENTS_MAX_AGE}" + SECRET_KEY: "${SECRET_KEY}" + networks: + - taiga + + taiga-gateway: + image: nginx:1.19-alpine + ports: + - "9000:80" + volumes: + - ./taiga-gateway/taiga.conf:/etc/nginx/conf.d/default.conf + - taiga-static-data:/taiga/static + - taiga-media-data:/taiga/media + networks: + - taiga + depends_on: + - taiga-front + - taiga-back + - taiga-events + +volumes: + taiga-static-data: + taiga-media-data: + taiga-db-data: + taiga-async-rabbitmq-data: + taiga-events-rabbitmq-data: + +networks: + taiga: diff --git a/taiga-docker-main/imgs/penpot.jpg b/taiga-docker-main/imgs/penpot.jpg new file mode 100644 index 000000000..8c28b969a Binary files /dev/null and b/taiga-docker-main/imgs/penpot.jpg differ diff --git a/taiga-docker-main/imgs/taiga.jpg b/taiga-docker-main/imgs/taiga.jpg new file mode 100644 index 000000000..1e9827561 Binary files /dev/null and b/taiga-docker-main/imgs/taiga.jpg differ diff --git a/taiga-docker-main/launch-taiga.sh b/taiga-docker-main/launch-taiga.sh new file mode 100644 index 000000000..e10a2b56b --- /dev/null +++ b/taiga-docker-main/launch-taiga.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +set -x +exec docker compose -f docker-compose.yml up -d $@ diff --git a/taiga-docker-main/taiga-gateway/taiga.conf b/taiga-docker-main/taiga-gateway/taiga.conf new file mode 100644 index 000000000..ebaeb6807 --- /dev/null +++ b/taiga-docker-main/taiga-gateway/taiga.conf @@ -0,0 +1,75 @@ +server { + listen 80 default_server; + + client_max_body_size 100M; + charset utf-8; + + # Frontend + location / { + proxy_pass http://taiga-front/; + proxy_pass_header Server; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + } + + # API + location /api/ { + proxy_pass http://taiga-back:8000/api/; + proxy_pass_header Server; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + } + + # Admin + location /admin/ { + proxy_pass http://taiga-back:8000/admin/; + proxy_pass_header Server; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + } + + # Static + location /static/ { + alias /taiga/static/; + } + + # Media + location /_protected/ { + internal; + alias /taiga/media/; + add_header Content-disposition "attachment"; + } + + # Unprotected section + location /media/exports/ { + alias /taiga/media/exports/; + add_header Content-disposition "attachment"; + } + + location /media/ { + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://taiga-protected:8003/; + proxy_redirect off; + } + + # Events + location /events { + proxy_pass http://taiga-events:8888/events; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } +} diff --git a/taiga-docker-main/taiga-manage.sh b/taiga-docker-main/taiga-manage.sh new file mode 100644 index 000000000..61843aa42 --- /dev/null +++ b/taiga-docker-main/taiga-manage.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +set -x +exec docker compose -f docker-compose.yml -f docker-compose-inits.yml run --rm taiga-manage $@ diff --git a/taiga/__init__.py b/taiga/__init__.py new file mode 100644 index 000000000..cead88637 --- /dev/null +++ b/taiga/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +__version__ = '6.8.2' diff --git a/taiga/auth/__init__.py b/taiga/auth/__init__.py new file mode 100644 index 000000000..4f7f3e3dc --- /dev/null +++ b/taiga/auth/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. diff --git a/taiga/auth/api.py b/taiga/auth/api.py new file mode 100644 index 000000000..150debfef --- /dev/null +++ b/taiga/auth/api.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from functools import partial + +from django.utils.translation import gettext as _ +from django.conf import settings + +from taiga.base import exceptions as exc +from taiga.base import response +from taiga.base.api import viewsets +from taiga.base.decorators import list_route +from taiga.projects.services.invitations import accept_invitation_by_existing_user + +from . import serializers +from .authentication import AUTH_HEADER_TYPES +from .permissions import AuthPermission +from .services import private_register_for_new_user +from .services import public_register +from .services import make_auth_response_data +from .services import get_auth_plugins +from .throttling import LoginFailRateThrottle, RegisterSuccessRateThrottle + + +def _validate_data(data:dict, *, cls): + """ + Generic function for parse and validate user + data using specified validator on `cls` + keyword parameter. + + Raises: RequestValidationError exception if + some errors found when data is validated. + """ + + validator = cls(data=data) + if not validator.is_valid(): + raise exc.RequestValidationError(validator.errors) + return validator.object + + +get_token = partial(_validate_data, cls=serializers.TokenObtainPairSerializer) +refresh_token = partial(_validate_data, cls=serializers.TokenRefreshSerializer) +verify_token = partial(_validate_data, cls=serializers.TokenVerifySerializer) +parse_public_register_data = partial(_validate_data, cls=serializers.PublicRegisterSerializer) +parse_private_register_data = partial(_validate_data, cls=serializers.PrivateRegisterSerializer) + + +class AuthViewSet(viewsets.ViewSet): + permission_classes = (AuthPermission,) + throttle_classes = (LoginFailRateThrottle, RegisterSuccessRateThrottle) + + serializer_class = None + + www_authenticate_realm = 'api' + + def get_authenticate_header(self, request): + return '{0} realm="{1}"'.format( + AUTH_HEADER_TYPES[0], + self.www_authenticate_realm, + ) + + # Login view: /api/v1/auth + def create(self, request, **kwargs): + self.check_permissions(request, 'get_token', None) + auth_plugins = get_auth_plugins() + + login_type = request.DATA.get("type", "").lower() + + if login_type == "normal": + # Default login process + data = get_token(request.DATA) + elif login_type in auth_plugins: + data = auth_plugins[login_type]['login_func'](request) + else: + raise exc.BadRequest(_("invalid login type")) + + # Processing invitation token + invitation_token = request.DATA.get("invitation_token", None) + if invitation_token: + accept_invitation_by_existing_user(invitation_token, data['id']) + + return response.Ok(data) + + # Refresh token view: /api/v1/auth/refresh + @list_route(methods=["POST"]) + def refresh(self, request, **kwargs): + self.check_permissions(request, 'refresh_token', None) + data = refresh_token(request.DATA) + return response.Ok(data) + + # Validate token view: /api/v1/auth/verify + @list_route(methods=["POST"]) + def verify(self, request, **kwargs): + if not settings.DEBUG: + return response.Forbidden() + + self.check_permissions(request, 'verify_token', None) + data = verify_token(request.DATA) + return response.Ok(data) + + + def _public_register(self, request): + if not settings.PUBLIC_REGISTER_ENABLED: + raise exc.BadRequest(_("Public registration is disabled.")) + + try: + data = parse_public_register_data(request.DATA) + user = public_register(**data) + except exc.IntegrityError as e: + raise exc.BadRequest(e.detail) + + data = make_auth_response_data(user) + return response.Created(data) + + def _private_register(self, request): + data = parse_private_register_data(request.DATA) + user = private_register_for_new_user(**data) + + data = make_auth_response_data(user) + return response.Created(data) + + # Register user: /api/v1/auth/register + @list_route(methods=["POST"]) + def register(self, request, **kwargs): + accepted_terms = request.DATA.get("accepted_terms", None) + if accepted_terms in (None, False): + raise exc.BadRequest(_("You must accept our terms of service and privacy policy")) + + self.check_permissions(request, 'register', None) + + type = request.DATA.get("type", None) + if type == "public": + return self._public_register(request) + elif type == "private": + return self._private_register(request) + raise exc.BadRequest(_("invalid registration type")) + diff --git a/taiga/auth/authentication.py b/taiga/auth/authentication.py new file mode 100644 index 000000000..55072e8ac --- /dev/null +++ b/taiga/auth/authentication.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ +from taiga.base.api import HTTP_HEADER_ENCODING, authentication + +from .exceptions import AuthenticationFailed, InvalidToken, TokenError +from .settings import api_settings + +AUTH_HEADER_TYPES = api_settings.AUTH_HEADER_TYPES + +if not isinstance(api_settings.AUTH_HEADER_TYPES, (list, tuple)): + AUTH_HEADER_TYPES = (AUTH_HEADER_TYPES,) + +AUTH_HEADER_TYPE_BYTES = set( + h.encode(HTTP_HEADER_ENCODING) + for h in AUTH_HEADER_TYPES +) + + +class JWTAuthentication(authentication.BaseAuthentication): + """ + An authentication plugin that authenticates requests through a JSON web + token provided in a request header. + """ + www_authenticate_realm = 'api' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user_model = get_user_model() + + def authenticate(self, request): + header = self.get_header(request) + if header is None: + return None + + raw_token = self.get_raw_token(header) + if raw_token is None: + return None + + validated_token = self.get_validated_token(raw_token) + + return self.get_user(validated_token), validated_token + + def authenticate_header(self, request): + return '{0} realm="{1}"'.format( + AUTH_HEADER_TYPES[0], + self.www_authenticate_realm, + ) + + def get_header(self, request): + """ + Extracts the header containing the JSON web token from the given + request. + """ + header = request.META.get(api_settings.AUTH_HEADER_NAME) + + if isinstance(header, str): + # Work around django test client oddness + header = header.encode(HTTP_HEADER_ENCODING) + + return header + + def get_raw_token(self, header): + """ + Extracts an unvalidated JSON web token from the given "Authorization" + header value. + """ + parts = header.split() + + if len(parts) == 0: + # Empty AUTHORIZATION header sent + return None + + if parts[0] not in AUTH_HEADER_TYPE_BYTES: + # Assume the header does not contain a JSON web token + return None + + if len(parts) != 2: + raise AuthenticationFailed( + _('Authorization header must contain two space-delimited values'), + code='bad_authorization_header', + ) + + return parts[1] + + def get_validated_token(self, raw_token): + """ + Validates an encoded JSON web token and returns a validated token + wrapper object. + """ + messages = [] + for AuthToken in api_settings.AUTH_TOKEN_CLASSES: + try: + return AuthToken(raw_token) + except TokenError as e: + messages.append({'token_class': AuthToken.__name__, + 'token_type': AuthToken.token_type, + 'message': e.args[0]}) + + raise InvalidToken({ + 'detail': _('Given token not valid for any token type'), + 'messages': messages, + }) + + def get_user(self, validated_token): + """ + Attempts to find and return a user using the given validated token. + """ + try: + user_id = validated_token[api_settings.USER_ID_CLAIM] + except KeyError: + raise InvalidToken(_('Token contained no recognizable user identification')) + + try: + user = self.user_model.objects.get(**{api_settings.USER_ID_FIELD: user_id}) + except self.user_model.DoesNotExist: + raise AuthenticationFailed(_('User not found'), code='user_not_found') + + if not user.is_active or user.is_system: + raise AuthenticationFailed(_('User is inactive'), code='user_inactive') + + return user + + +class JWTTokenUserAuthentication(JWTAuthentication): + def get_user(self, validated_token): + """ + Returns a stateless user object which is backed by the given validated + token. + """ + if api_settings.USER_ID_CLAIM not in validated_token: + # The TokenUser class assumes tokens will have a recognizable user + # identifier claim. + raise InvalidToken(_('Token contained no recognizable user identification')) + + return api_settings.TOKEN_USER_CLASS(validated_token) diff --git a/taiga/auth/backends.py b/taiga/auth/backends.py new file mode 100644 index 000000000..9237a79ba --- /dev/null +++ b/taiga/auth/backends.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import jwt +from django.utils.translation import gettext_lazy as _ +from jwt import InvalidAlgorithmError, InvalidTokenError, algorithms + +from taiga.base.api.authentication import BaseAuthentication + +from .exceptions import TokenBackendError +from .utils import format_lazy + + +class Session(BaseAuthentication): + """ + Session based authentication like the standard + `taiga.base.api.authentication.SessionAuthentication` + but with csrf disabled (for obvious reasons because + it is for api. + NOTE: this is only for api web interface. Is not used + for common api usage and should be disabled on production. + """ + + def authenticate(self, request): + http_request = request._request + user = getattr(http_request, 'user', None) + + if not user or not user.is_active: + return None + + return (user, None) + + +ALLOWED_ALGORITHMS = ( + 'HS256', + 'HS384', + 'HS512', + 'RS256', + 'RS384', + 'RS512', +) + + +class TokenBackend: + def __init__(self, algorithm, signing_key=None, verifying_key=None, audience=None, issuer=None): + self._validate_algorithm(algorithm) + + self.algorithm = algorithm + self.signing_key = signing_key + self.audience = audience + self.issuer = issuer + if algorithm.startswith('HS'): + self.verifying_key = signing_key + else: + self.verifying_key = verifying_key + + def _validate_algorithm(self, algorithm): + """ + Ensure that the nominated algorithm is recognized, and that cryptography is installed for those + algorithms that require it + """ + if algorithm not in ALLOWED_ALGORITHMS: + raise TokenBackendError(format_lazy(_("Unrecognized algorithm type '{}'"), algorithm)) + + if algorithm in algorithms.requires_cryptography and not algorithms.has_crypto: + raise TokenBackendError(format_lazy(_("You must have cryptography installed to use {}."), algorithm)) + + def encode(self, payload): + """ + Returns an encoded token for the given payload dictionary. + """ + jwt_payload = payload.copy() + if self.audience is not None: + jwt_payload['aud'] = self.audience + if self.issuer is not None: + jwt_payload['iss'] = self.issuer + + token = jwt.encode(jwt_payload, self.signing_key, algorithm=self.algorithm) + if isinstance(token, bytes): + # For PyJWT <= 1.7.1 + return token.decode('utf-8') + # For PyJWT >= 2.0.0a1 + return token + + def decode(self, token, verify=True): + """ + Performs a validation of the given token and returns its payload + dictionary. + + Raises a `TokenBackendError` if the token is malformed, if its + signature check fails, or if its 'exp' claim indicates it has expired. + """ + try: + return jwt.decode( + token, self.verifying_key, algorithms=[self.algorithm], verify=verify, + audience=self.audience, issuer=self.issuer, + options={'verify_aud': self.audience is not None, "verify_signature": verify} + ) + except InvalidAlgorithmError as ex: + raise TokenBackendError(_('Invalid algorithm specified')) from ex + except InvalidTokenError: + raise TokenBackendError(_('Token is invalid or expired')) diff --git a/taiga/auth/compat.py b/taiga/auth/compat.py new file mode 100644 index 000000000..37602dfe9 --- /dev/null +++ b/taiga/auth/compat.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import warnings + +try: + from django.urls import reverse, reverse_lazy +except ImportError: + from django.core.urlresolvers import reverse, reverse_lazy # NOQA + + +class RemovedInDjango20Warning(DeprecationWarning): + pass + + +class CallableBool: # pragma: no cover + """ + An boolean-like object that is also callable for backwards compatibility. + """ + do_not_call_in_templates = True + + def __init__(self, value): + self.value = value + + def __bool__(self): + return self.value + + def __call__(self): + warnings.warn( + "Using user.is_authenticated() and user.is_anonymous() as a method " + "is deprecated. Remove the parentheses to use it as an attribute.", + RemovedInDjango20Warning, stacklevel=2 + ) + return self.value + + def __nonzero__(self): # Python 2 compatibility + return self.value + + def __repr__(self): + return 'CallableBool(%r)' % self.value + + def __eq__(self, other): + return self.value == other + + def __ne__(self, other): + return self.value != other + + def __or__(self, other): + return bool(self.value or other) + + def __hash__(self): + return hash(self.value) + + +CallableFalse = CallableBool(False) +CallableTrue = CallableBool(True) diff --git a/taiga/auth/exceptions.py b/taiga/auth/exceptions.py new file mode 100644 index 000000000..52f12a61d --- /dev/null +++ b/taiga/auth/exceptions.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.utils.translation import gettext_lazy as _ +from taiga.base import exceptions, status + + +class TokenError(Exception): + pass + + +class TokenBackendError(Exception): + pass + + +class DetailDictMixin: + def __init__(self, detail=None, code=None): + """ + Builds a detail dictionary for the error to give more information to API + users. + """ + detail_dict = {'detail': self.default_detail, 'code': self.default_code} + + if isinstance(detail, dict): + detail_dict.update(detail) + elif detail is not None: + detail_dict['detail'] = detail + + if code is not None: + detail_dict['code'] = code + + super().__init__(detail_dict) + + +class AuthenticationFailed(DetailDictMixin, exceptions.AuthenticationFailed): + default_code = 'authentication_failed' + + +class InvalidToken(AuthenticationFailed): + status_code = status.HTTP_401_UNAUTHORIZED + default_detail = _('Token is invalid or expired') + default_code = 'token_not_valid' diff --git a/taiga/auth/models.py b/taiga/auth/models.py new file mode 100644 index 000000000..f51e81174 --- /dev/null +++ b/taiga/auth/models.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from django.contrib.auth import models as auth_models +from django.db.models.manager import EmptyManager +from django.utils.functional import cached_property + +from .compat import CallableFalse, CallableTrue +from .settings import api_settings + + +class TokenUser: + """ + A dummy user class modeled after django.contrib.auth.models.AnonymousUser. + Used in conjunction with the `JWTTokenUserAuthentication` backend to + implement single sign-on functionality across services which share the same + secret key. `JWTTokenUserAuthentication` will return an instance of this + class instead of a `User` model instance. Instances of this class act as + stateless user objects which are backed by validated tokens. + """ + # User is always active since Simple JWT will never issue a token for an + # inactive user + is_active = True + + _groups = EmptyManager(auth_models.Group) + _user_permissions = EmptyManager(auth_models.Permission) + + def __init__(self, token): + self.token = token + + def __str__(self): + return 'TokenUser {}'.format(self.id) + + @cached_property + def id(self): + return self.token[api_settings.USER_ID_CLAIM] + + @cached_property + def pk(self): + return self.id + + @cached_property + def username(self): + return self.token.get('username', '') + + @cached_property + def is_staff(self): + return self.token.get('is_staff', False) + + @cached_property + def is_superuser(self): + return self.token.get('is_superuser', False) + + def __eq__(self, other): + return self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self.id) + + def save(self): + raise NotImplementedError('Token users have no DB representation') + + def delete(self): + raise NotImplementedError('Token users have no DB representation') + + def set_password(self, raw_password): + raise NotImplementedError('Token users have no DB representation') + + def check_password(self, raw_password): + raise NotImplementedError('Token users have no DB representation') + + @property + def groups(self): + return self._groups + + @property + def user_permissions(self): + return self._user_permissions + + def get_group_permissions(self, obj=None): + return set() + + def get_all_permissions(self, obj=None): + return set() + + def has_perm(self, perm, obj=None): + return False + + def has_perms(self, perm_list, obj=None): + return False + + def has_module_perms(self, module): + return False + + @property + def is_anonymous(self): + return CallableFalse + + @property + def is_authenticated(self): + return CallableTrue + + def get_username(self): + return self.username diff --git a/taiga/auth/permissions.py b/taiga/auth/permissions.py new file mode 100644 index 000000000..e0e1d6387 --- /dev/null +++ b/taiga/auth/permissions.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission, AllowAny + + +class AuthPermission(TaigaResourcePermission): + get_token_perms = AllowAny() + refresh_token_perms = AllowAny() + verify_token_perms = AllowAny() + register_perms = AllowAny() diff --git a/taiga/auth/serializers.py b/taiga/auth/serializers.py new file mode 100644 index 000000000..d1cde9d59 --- /dev/null +++ b/taiga/auth/serializers.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import bleach +import re + +from django.core import validators as core_validators +from django.utils.translation import gettext as _ + +from taiga.base.api import serializers +from taiga.base.exceptions import ValidationError + +from .services import login, refresh_token, verify_token + + +class TokenObtainPairSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField(write_only=True) + + def validate(self, attrs): + authenticate_kwargs = { + 'username': attrs['username'], + 'password': attrs['password'], + } + + return login(**authenticate_kwargs) + + +class TokenRefreshSerializer(serializers.Serializer): + refresh = serializers.CharField() + + def validate(self, attrs): + return refresh_token(attrs['refresh']) + + +class TokenVerifySerializer(serializers.Serializer): + token = serializers.CharField() + + def validate(self, attrs): + return verify_token(attrs['token']) + + + +class BaseRegisterSerializer(serializers.Serializer): + full_name = serializers.CharField(max_length=36) + email = serializers.EmailField(max_length=255) + username = serializers.CharField(max_length=255) + password = serializers.CharField(min_length=6) + + def validate_username(self, attrs, source): + value = attrs[source] + validator = core_validators.RegexValidator(re.compile(r'^[\w.-]+$'), _("invalid username"), "invalid") + + try: + validator(value) + except ValidationError: + raise ValidationError(_("Required. 255 characters or fewer. Letters, numbers " + "and /./-/_ characters'")) + return attrs + + def validate_full_name(self, attrs, source): + value = attrs[source] + if value != bleach.clean(value): + raise ValidationError(_("Invalid full name")) + + if re.search(r"http[s]?:", value): + raise ValidationError(_("Invalid full name")) + + return attrs + + +class PublicRegisterSerializer(BaseRegisterSerializer): + pass + + +class PrivateRegisterSerializer(BaseRegisterSerializer): + token = serializers.CharField(max_length=255, required=True) diff --git a/taiga/auth/services.py b/taiga/auth/services.py new file mode 100644 index 000000000..9daacf678 --- /dev/null +++ b/taiga/auth/services.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# + +from typing import Callable +import uuid + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import update_last_login +from django.db import IntegrityError +from django.db import transaction as tx +from django.utils.translation import gettext_lazy as _ + +from taiga.base import exceptions as exc +from taiga.base.mails import mail_builder +from taiga.users.models import User +from taiga.users.serializers import UserAdminSerializer +from taiga.users.services import get_and_validate_user +from taiga.projects.services.invitations import get_membership_by_token + +from .exceptions import AuthenticationFailed, InvalidToken, TokenError +from .settings import api_settings +from .tokens import RefreshToken, CancelToken, UntypedToken +from .signals import user_registered as user_registered_signal + + +##################### +## AUTH PLUGINS +##################### + +auth_plugins = {} + + +def register_auth_plugin(name: str, login_func: Callable): + auth_plugins[name] = { + "login_func": login_func, + } + + +def get_auth_plugins(): + return auth_plugins + + +##################### +## AUTH SERVICES +##################### + +def make_auth_response_data(user): + serializer = UserAdminSerializer(user) + data = dict(serializer.data) + + refresh = RefreshToken.for_user(user) + + data['refresh'] = str(refresh) + data['auth_token'] = str(refresh.access_token) + + if api_settings.UPDATE_LAST_LOGIN: + update_last_login(None, user) + + return data + + +def login(username: str, password: str): + try: + user = get_and_validate_user(username=username, password=password) + except exc.WrongArguments: + raise AuthenticationFailed( + _('No active account found with the given credentials'), + 'invalid_credentials', + ) + + # Generate data + return make_auth_response_data(user) + + +def refresh_token(refresh_token: str): + try: + refresh = RefreshToken(refresh_token) + except TokenError: + raise InvalidToken() + + data = {'auth_token': str(refresh.access_token)} + + if api_settings.ROTATE_REFRESH_TOKENS: + if api_settings.DENYLIST_AFTER_ROTATION: + try: + # Attempt to denylist the given refresh token + refresh.denylist() + except AttributeError: + # If denylist app not installed, `denylist` method will + # not be present + pass + + refresh.set_jti() + refresh.set_exp() + + data['refresh'] = str(refresh) + + return data + + +def verify_token(token: str): + UntypedToken(token) + return {} + + +##################### +## REGISTER SERVICES +##################### + +def send_register_email(user) -> bool: + """ + Given a user, send register welcome email + message to specified user. + """ + cancel_token = CancelToken.for_user(user) + context = {"user": user, "cancel_token": str(cancel_token)} + email = mail_builder.registered_user(user, context) + return bool(email.send()) + + +def is_user_already_registered(*, username:str, email:str) -> (bool, str): + """ + Checks if a specified user is already registred. + + Returns a tuple containing a boolean value that indicates if the user exists + and in case he does whats the duplicated attribute + """ + user_model = get_user_model() + if user_model.objects.filter(username__iexact=username).exists(): + return (True, _("Username is already in use.")) + + if user_model.objects.filter(email__iexact=email).exists(): + return (True, _("Email is already in use.")) + + return (False, None) + + +@tx.atomic +def public_register(username:str, password:str, email:str, full_name:str): + """ + Given a parsed parameters, try register a new user + knowing that it follows a public register flow. + + This can raise `exc.IntegrityError` exceptions in + case of conflics found. + + :returns: User + """ + + is_registered, reason = is_user_already_registered(username=username, email=email) + if is_registered: + raise exc.WrongArguments(reason) + + user_model = get_user_model() + user = user_model(username=username, + email=email, + email_token=str(uuid.uuid4()), + new_email=email, + verified_email=False, + full_name=full_name, + read_new_terms=True) + user.set_password(password) + try: + user.save() + except IntegrityError: + raise exc.WrongArguments(_("User is already registered.")) + + send_register_email(user) + user_registered_signal.send(sender=user.__class__, user=user) + return user + + +@tx.atomic +def private_register_for_new_user(token:str, username:str, email:str, + full_name:str, password:str): + """ + Given a inviation token, try register new user matching + the invitation token. + """ + is_registered, reason = is_user_already_registered(username=username, email=email) + if is_registered: + raise exc.WrongArguments(reason) + + user_model = get_user_model() + user = user_model(username=username, + email=email, + full_name=full_name, + email_token=str(uuid.uuid4()), + new_email=email, + verified_email=False, + read_new_terms=True) + + user.set_password(password) + try: + user.save() + except IntegrityError: + raise exc.WrongArguments(_("Error while creating new user.")) + + membership = get_membership_by_token(token) + membership.user = user + membership.save(update_fields=["user"]) + send_register_email(user) + user_registered_signal.send(sender=user.__class__, user=user) + + return user diff --git a/taiga/auth/settings.py b/taiga/auth/settings.py new file mode 100644 index 000000000..5d1beacad --- /dev/null +++ b/taiga/auth/settings.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Settings +======== + +Some of Simple JWT's behavior can be customized through settings variables in +``settings`` module: + +.. code-block:: python + + # Django project settings.py + + from datetime import timedelta + + ... + + SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), + 'CANCEL_TOKEN_LIFETIME': timedelta(days=30), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': False, + 'DENYLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': False, + + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': settings.SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'USER_AUTHENTICATION_RULE': 'taiga.auth.authentication.default_user_authentication_rule', + + 'AUTH_TOKEN_CLASSES': ('taiga.auth.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + + 'JTI_CLAIM': 'jti', + } + +Above, the default values for these settings are shown. + +``ACCESS_TOKEN_LIFETIME`` +------------------------- + +A ``datetime.timedelta`` object which specifies how long access tokens are +valid. This ``timedelta`` value is added to the current UTC time during token +generation to obtain the token's default "exp" claim value. + +``REFRESH_TOKEN_LIFETIME`` +-------------------------- + +A ``datetime.timedelta`` object which specifies how long refresh tokens are +valid. This ``timedelta`` value is added to the current UTC time during token +generation to obtain the token's default "exp" claim value. + +``CANCEL_TOKEN_LIFETIME`` +-------------------------- + +A ``datetime.timedelta`` object which specifies how long cancel tokens are +valid. This ``timedelta`` value is added to the current UTC time during token +generation to obtain the token's default "exp" claim value. + +``ROTATE_REFRESH_TOKENS`` +------------------------- + +When set to ``True``, if a refresh token is submitted to the +``TokenRefreshView``, a new refresh token will be returned along with the new +access token. This new refresh token will be supplied via a "refresh" key in +the JSON response. New refresh tokens will have a renewed expiration time +which is determined by adding the timedelta in the ``REFRESH_TOKEN_LIFETIME`` +setting to the current time when the request is made. If the denylist app is +in use and the ``DENYLIST_AFTER_ROTATION`` setting is set to ``True``, refresh +tokens submitted to the refresh view will be added to the denylist. + +``DENYLIST_AFTER_ROTATION`` +---------------------------- + +When set to ``True``, causes refresh tokens submitted to the +``TokenRefreshView`` to be added to the denylist if the +``ROTATE_REFRESH_TOKENS`` setting is set to ``True``. + +``UPDATE_LAST_LOGIN`` +---------------------------- + +When set to ``True``, last_login field in the auth_user table is updated upon +login (TokenObtainPairView). + + Warning: Updating last_login will dramatically increase the number of database + transactions. People abusing the views could slow the server and this could be + a security vulnerability. If you really want this, throttle the endpoint with + DRF at the very least. + +``ALGORITHM`` +------------- + +The algorithm from the PyJWT library which will be used to perform +signing/verification operations on tokens. To use symmetric HMAC signing and +verification, the following algorithms may be used: ``'HS256'``, ``'HS384'``, +``'HS512'``. When an HMAC algorithm is chosen, the ``SIGNING_KEY`` setting +will be used as both the signing key and the verifying key. In that case, the +``VERIFYING_KEY`` setting will be ignored. To use asymmetric RSA signing and +verification, the following algorithms may be used: ``'RS256'``, ``'RS384'``, +``'RS512'``. When an RSA algorithm is chosen, the ``SIGNING_KEY`` setting must +be set to a string that contains an RSA private key. Likewise, the +``VERIFYING_KEY`` setting must be set to a string that contains an RSA public +key. + +``SIGNING_KEY`` +--------------- + +The signing key that is used to sign the content of generated tokens. For HMAC +signing, this should be a random string with at least as many bits of data as +is required by the signing protocol. For RSA signing, this should be a string +that contains an RSA private key that is 2048 bits or longer. Since Simple JWT +defaults to using 256-bit HMAC signing, the ``SIGNING_KEY`` setting defaults to +the value of the ``SECRET_KEY`` setting for your django project. Although this +is the most reasonable default that Simple JWT can provide, it is recommended +that developers change this setting to a value that is independent from the +django project secret key. This will make changing the signing key used for +tokens easier in the event that it is compromised. + +``VERIFYING_KEY`` +----------------- + +The verifying key which is used to verify the content of generated tokens. If +an HMAC algorithm has been specified by the ``ALGORITHM`` setting, the +``VERIFYING_KEY`` setting will be ignored and the value of the ``SIGNING_KEY`` +setting will be used. If an RSA algorithm has been specified by the +``ALGORITHM`` setting, the ``VERIFYING_KEY`` setting must be set to a string +that contains an RSA public key. + +``AUDIENCE`` +------------- + +The audience claim to be included in generated tokens and/or validated in +decoded tokens. When set to ``None``, this field is excluded from tokens and is +not validated. + +``ISSUER`` +---------- + +The issuer claim to be included in generated tokens and/or validated in decoded +tokens. When set to ``None``, this field is excluded from tokens and is not +validated. + +``AUTH_HEADER_TYPES`` +--------------------- + +The authorization header type(s) that will be accepted for views that require +authentication. For example, a value of ``'Bearer'`` means that views +requiring authentication would look for a header with the following format: +``Authorization: Bearer ``. This setting may also contain a list or +tuple of possible header types (e.g. ``('Bearer', 'JWT')``). If a list or +tuple is used in this way, and authentication fails, the first item in the +collection will be used to build the "WWW-Authenticate" header in the response. + +``AUTH_HEADER_NAME`` +---------------------------- + +The authorization header name to be used for authentication. +The default is ``HTTP_AUTHORIZATION`` which will accept the +``Authorization`` header in the request. For example if you'd +like to use ``X_Access_Token`` in the header of your requests +please specify the ``AUTH_HEADER_NAME`` to be +``HTTP_X_ACCESS_TOKEN`` in your settings. + +``USER_ID_FIELD`` +----------------- + +The database field from the user model that will be included in generated +tokens to identify users. It is recommended that the value of this setting +specifies a field that does not normally change once its initial value is +chosen. For example, specifying a "username" or "email" field would be a poor +choice since an account's username or email might change depending on how +account management in a given service is designed. This could allow a new +account to be created with an old username while an existing token is still +valid which uses that username as a user identifier. + +``USER_ID_CLAIM`` +----------------- + +The claim in generated tokens which will be used to store user identifiers. +For example, a setting value of ``'user_id'`` would mean generated tokens +include a "user_id" claim that contains the user's identifier. + +``USER_AUTHENTICATION_RULE`` +---------------------------- + +Callable to determine if the user is permitted to authenticate. This rule +is applied after a valid token is processed. The user object is passed +to the callable as an argument. The default rule is to check that the ``is_active`` +flag is still ``True``. The callable must return a boolean, ``True`` if authorized, +``False`` otherwise resulting in a 401 status code. + +``AUTH_TOKEN_CLASSES`` +---------------------- + +A list of dot paths to classes that specify the types of token that are allowed +to prove authentication. More about this in the "Token types" section below. + +``TOKEN_TYPE_CLAIM`` +-------------------- + +The claim name that is used to store a token's type. More about this in the +"Token types" section below. + +``JTI_CLAIM`` +------------- + +The claim name that is used to store a token's unique identifier. This +identifier is used to identify revoked tokens in the denylist app. It may be +necessary in some cases to use another claim besides the default "jti" claim to +store such a value. +""" + + +from datetime import timedelta + +from django.conf import settings +from django.test.signals import setting_changed +from django.utils.translation import gettext_lazy as _ +from taiga.base.api.settings import APISettings + +from .utils import format_lazy + +USER_SETTINGS = getattr(settings, 'SIMPLE_JWT', None) + +DEFAULTS = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=3), + 'CANCEL_TOKEN_LIFETIME': timedelta(days=100), + 'ROTATE_REFRESH_TOKENS': True, + 'DENYLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': True, + + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': settings.SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + + 'AUTH_TOKEN_CLASSES': ('taiga.auth.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + + 'JTI_CLAIM': 'jti', + 'TOKEN_USER_CLASS': 'taiga.auth.models.TokenUser', +} + +IMPORT_STRINGS = ( + 'AUTH_TOKEN_CLASSES', + 'TOKEN_USER_CLASS', +) + +api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) + + +def reload_api_settings(*args, **kwargs): # pragma: no cover + global api_settings + + setting, value = kwargs['setting'], kwargs['value'] + + if setting == 'SIMPLE_JWT': + api_settings = APISettings(value, DEFAULTS, IMPORT_STRINGS) + + +setting_changed.connect(reload_api_settings) diff --git a/taiga/auth/signals.py b/taiga/auth/signals.py new file mode 100644 index 000000000..e8400c843 --- /dev/null +++ b/taiga/auth/signals.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import django.dispatch + + +user_registered = django.dispatch.Signal() # providing_args=["user"] diff --git a/taiga/auth/state.py b/taiga/auth/state.py new file mode 100644 index 000000000..bdfb6cfb7 --- /dev/null +++ b/taiga/auth/state.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from .backends import TokenBackend +from .settings import api_settings + +token_backend = TokenBackend(api_settings.ALGORITHM, api_settings.SIGNING_KEY, + api_settings.VERIFYING_KEY, api_settings.AUDIENCE, api_settings.ISSUER) diff --git a/taiga/auth/throttling.py b/taiga/auth/throttling.py new file mode 100644 index 000000000..90fce8142 --- /dev/null +++ b/taiga/auth/throttling.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import throttling + + +class LoginFailRateThrottle(throttling.GlobalThrottlingMixin, throttling.ThrottleByActionMixin, throttling.SimpleRateThrottle): + scope = "login-fail" + throttled_actions = ["create", "refresh", "verify"] + + def throttle_success(self, request, view): + return True + + def finalize(self, request, response, view): + if response.status_code in [400, 401]: + self.history.insert(0, self.now) + self.cache.set(self.key, self.history, self.duration) + + +class RegisterSuccessRateThrottle(throttling.GlobalThrottlingMixin, throttling.ThrottleByActionMixin, throttling.SimpleRateThrottle): + scope = "register-success" + throttled_actions = ["register"] + + def throttle_success(self, request, view): + return True + + def finalize(self, request, response, view): + if response.status_code == 201: + self.history.insert(0, self.now) + self.cache.set(self.key, self.history, self.duration) + diff --git a/taiga/auth/token_denylist/__init__.py b/taiga/auth/token_denylist/__init__.py new file mode 100644 index 000000000..8f8f4feff --- /dev/null +++ b/taiga/auth/token_denylist/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + diff --git a/taiga/auth/token_denylist/admin.py b/taiga/auth/token_denylist/admin.py new file mode 100644 index 000000000..12770f731 --- /dev/null +++ b/taiga/auth/token_denylist/admin.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import DenylistedToken, OutstandingToken + + +@admin.register(OutstandingToken) +class OutstandingTokenAdmin(admin.ModelAdmin): + list_display = ( + 'jti', + 'user', + 'created_at', + 'expires_at', + ) + search_fields = ( + 'user__id', + 'jti', + ) + ordering = ( + 'user', + ) + + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + + return qs.select_related('user') + + # Read-only behavior defined below + actions = None + + def get_readonly_fields(self, *args, **kwargs): + return [f.name for f in self.model._meta.fields] + + def has_add_permission(self, *args, **kwargs): + return False + + def has_delete_permission(self, *args, **kwargs): + return False + + def has_change_permission(self, request, obj=None): + return ( + request.method in ['GET', 'HEAD'] and # noqa: W504 + super().has_change_permission(request, obj) + ) + + + + +@admin.register(DenylistedToken) +class DenylistedTokenAdmin(admin.ModelAdmin): + list_display = ( + 'token_jti', + 'token_user', + 'token_created_at', + 'token_expires_at', + 'denylisted_at', + ) + search_fields = ( + 'token__user__id', + 'token__jti', + ) + ordering = ( + 'token__user', + ) + + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + + return qs.select_related('token__user') + + @admin.display( + description=_('jti'), + ordering='token__jti', + ) + def token_jti(self, obj): + return obj.token.jti + + @admin.display( + description=_('user'), + ordering='token__user', + ) + def token_user(self, obj): + return obj.token.user + + @admin.display( + description=_('created at'), + ordering='token__created_at', + ) + def token_created_at(self, obj): + return obj.token.created_at + + @admin.display( + description=_('expires at'), + ordering='token__expires_at', + ) + def token_expires_at(self, obj): + return obj.token.expires_at + + diff --git a/taiga/auth/token_denylist/apps.py b/taiga/auth/token_denylist/apps.py new file mode 100644 index 000000000..56b7d0ecf --- /dev/null +++ b/taiga/auth/token_denylist/apps.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Denylist app +============= + +This app provides token denylist functionality. + +If the denylist app is detected in ``INSTALLED_APPS``, Taiga Auth will add any +generated refresh token to a list of outstanding tokens. It will also check +that any refresh token does not appear in a denylist of tokens before it +considers it as valid. + +The denylist app implements its outstanding and denylisted token lists using +two models: ``OutstandingToken`` and ``DenylistedToken``. Model admins are +defined for both of these models. To add a token to the denylist, find its +corresponding ``OutstandingToken`` record in the admin and use the admin again +to create a ``DenylistedToken`` record that points to the ``OutstandingToken`` +record. + +Alternatively, you can denylist a token by creating a ``DenylistMixin`` +subclass instance and calling the instance's ``denylist`` method: + +.. code-block:: python + + from rest_framework_simplejwt.tokens import RefreshToken + + token = RefreshToken(base64_encoded_token_string) + token.denylist() + +This will create unique outstanding token and denylist records for the token's +"jti" claim or whichever claim is specified by the ``JTI_CLAIM`` setting. + +The denylist app also provides a management command, ``flushexpiredtokens``, +which will delete any tokens from the outstanding list and denylist that have +expired. +""" + +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class TokenDenylistConfig(AppConfig): + name = 'taiga.auth.token_denylist' + verbose_name = _('Token Denylist') + default_auto_field = 'django.db.models.BigAutoField' diff --git a/taiga/auth/token_denylist/management/__init__.py b/taiga/auth/token_denylist/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/taiga/auth/token_denylist/management/commands/__init__.py b/taiga/auth/token_denylist/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/taiga/auth/token_denylist/management/commands/flushexpiredtokens.py b/taiga/auth/token_denylist/management/commands/flushexpiredtokens.py new file mode 100644 index 000000000..e4c55e418 --- /dev/null +++ b/taiga/auth/token_denylist/management/commands/flushexpiredtokens.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.core.management.base import BaseCommand + +from taiga.auth.utils import aware_utcnow + +from ...models import OutstandingToken + + +class Command(BaseCommand): + help = 'Flushes any expired tokens in the outstanding token list' + + def handle(self, *args, **kwargs): + OutstandingToken.objects.filter(expires_at__lte=aware_utcnow()).delete() diff --git a/taiga/auth/token_denylist/migrations/0001_initial.py b/taiga/auth/token_denylist/migrations/0001_initial.py new file mode 100644 index 000000000..df4076384 --- /dev/null +++ b/taiga/auth/token_denylist/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.23 on 2021-06-23 09:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OutstandingToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('jti', models.CharField(max_length=255, unique=True)), + ('token', models.TextField()), + ('created_at', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField()), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('user',), + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DenylistedToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('denylisted_at', models.DateTimeField(auto_now_add=True)), + ('token', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='token_denylist.OutstandingToken')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/taiga/auth/token_denylist/migrations/__init__.py b/taiga/auth/token_denylist/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/taiga/auth/token_denylist/models.py b/taiga/auth/token_denylist/models.py new file mode 100644 index 000000000..4eab0960a --- /dev/null +++ b/taiga/auth/token_denylist/models.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from django.conf import settings +from django.db import models + + +class OutstandingToken(models.Model): + id = models.BigAutoField(primary_key=True, serialize=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) + + jti = models.CharField(unique=True, max_length=255) + token = models.TextField() + + created_at = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField() + + class Meta: + # Work around for a bug in Django: + # https://code.djangoproject.com/ticket/19422 + # + # Also see corresponding ticket: + # https://github.com/encode/django-rest-framework/issues/705 + abstract = 'taiga.auth.token_denylist' not in settings.INSTALLED_APPS + ordering = ('user',) + + def __str__(self): + return 'Token for {} ({})'.format( + self.user, + self.jti, + ) + + +class DenylistedToken(models.Model): + id = models.BigAutoField(primary_key=True, serialize=False) + token = models.OneToOneField(OutstandingToken, on_delete=models.CASCADE) + + denylisted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + # Work around for a bug in Django: + # https://code.djangoproject.com/ticket/19422 + # + # Also see corresponding ticket: + # https://github.com/encode/django-rest-framework/issues/705 + abstract = 'taiga.auth.token_denylist' not in settings.INSTALLED_APPS + + def __str__(self): + return 'Denylisted token for {}'.format(self.token.user) diff --git a/taiga/auth/token_denylist/tasks.py b/taiga/auth/token_denylist/tasks.py new file mode 100644 index 000000000..20577e6e6 --- /dev/null +++ b/taiga/auth/token_denylist/tasks.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + +from django.core.management import call_command + +from taiga.celery import app + + +@app.task +def flush_expired_tokens(): + """Flushes any expired tokens in the outstanding token list.""" + call_command('flushexpiredtokens') diff --git a/taiga/auth/tokens.py b/taiga/auth/tokens.py new file mode 100644 index 000000000..802d245e3 --- /dev/null +++ b/taiga/auth/tokens.py @@ -0,0 +1,326 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from datetime import timedelta +from uuid import uuid4 + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from .exceptions import TokenBackendError, TokenError +from .settings import api_settings +from .token_denylist.models import DenylistedToken, OutstandingToken +from .utils import ( + aware_utcnow, datetime_from_epoch, datetime_to_epoch, format_lazy, +) + + +class Token: + """ + A class which validates and wraps an existing JWT or can be used to build a + new JWT. + """ + token_type = None + lifetime = None + + def __init__(self, token=None, verify=True): + """ + !!!! IMPORTANT !!!! MUST raise a TokenError with a user-facing error + message if the given token is invalid, expired, or otherwise not safe + to use. + """ + if self.token_type is None or self.lifetime is None: + raise TokenError(_('Cannot create token with no type or lifetime')) + + self.token = token + self.current_time = aware_utcnow() + + # Set up token + if token is not None: + # An encoded token was provided + token_backend = self.get_token_backend() + + # Decode token + try: + self.payload = token_backend.decode(token, verify=verify) + except TokenBackendError: + raise TokenError(_('Token is invalid or expired')) + + if verify: + self.verify() + else: + # New token. Skip all the verification steps. + self.payload = {api_settings.TOKEN_TYPE_CLAIM: self.token_type} + + # Set "exp" claim with default value + self.set_exp(from_time=self.current_time, lifetime=self.lifetime) + + # Set "jti" claim + self.set_jti() + + def __repr__(self): + return repr(self.payload) + + def __getitem__(self, key): + return self.payload[key] + + def __setitem__(self, key, value): + self.payload[key] = value + + def __delitem__(self, key): + del self.payload[key] + + def __contains__(self, key): + return key in self.payload + + def get(self, key, default=None): + return self.payload.get(key, default) + + def __str__(self): + """ + Signs and returns a token as a base64 encoded string. + """ + return self.get_token_backend().encode(self.payload) + + def verify(self): + """ + Performs additional validation steps which were not performed when this + token was decoded. This method is part of the "public" API to indicate + the intention that it may be overridden in subclasses. + """ + # According to RFC 7519, the "exp" claim is OPTIONAL + # (https://tools.ietf.org/html/rfc7519#section-4.1.4). As a more + # correct behavior for authorization tokens, we require an "exp" + # claim. We don't want any zombie tokens walking around. + self.check_exp() + + # Ensure token id is present + if api_settings.JTI_CLAIM not in self.payload: + raise TokenError(_('Token has no id')) + + self.verify_token_type() + + def verify_token_type(self): + """ + Ensures that the token type claim is present and has the correct value. + """ + try: + token_type = self.payload[api_settings.TOKEN_TYPE_CLAIM] + except KeyError: + raise TokenError(_('Token has no type')) + + if self.token_type != token_type: + raise TokenError(_('Token has wrong type')) + + def set_jti(self): + """ + Populates the configured jti claim of a token with a string where there + is a negligible probability that the same string will be chosen at a + later time. + + See here: + https://tools.ietf.org/html/rfc7519#section-4.1.7 + """ + self.payload[api_settings.JTI_CLAIM] = uuid4().hex + + def set_exp(self, claim='exp', from_time=None, lifetime=None): + """ + Updates the expiration time of a token. + """ + if from_time is None: + from_time = self.current_time + + if lifetime is None: + lifetime = self.lifetime + + self.payload[claim] = datetime_to_epoch(from_time + lifetime) + + def check_exp(self, claim='exp', current_time=None): + """ + Checks whether a timestamp value in the given claim has passed (since + the given datetime value in `current_time`). Raises a TokenError with + a user-facing error message if so. + """ + if current_time is None: + current_time = self.current_time + + try: + claim_value = self.payload[claim] + except KeyError: + raise TokenError(format_lazy(_("Token has no '{}' claim"), claim)) + + claim_time = datetime_from_epoch(claim_value) + if claim_time <= current_time: + raise TokenError(format_lazy(_("Token '{}' claim has expired"), claim)) + + @classmethod + def for_user(cls, user): + """ + Returns an authorization token for the given user that will be provided + after authenticating the user's credentials. + """ + user_id = getattr(user, api_settings.USER_ID_FIELD) + if not isinstance(user_id, int): + user_id = str(user_id) + + token = cls() + token[api_settings.USER_ID_CLAIM] = user_id + + return token + + def get_token_backend(self): + from .state import token_backend + return token_backend + + +class DenylistMixin: + """ + If the `taiga.auth.token_denylist` app was configured to be + used, tokens created from `DenylistMixin` subclasses will insert + themselves into an outstanding token list and also check for their + membership in a token denylist. + """ + if 'taiga.auth.token_denylist' in settings.INSTALLED_APPS: + def verify(self, *args, **kwargs): + self.check_denylist() + + super().verify(*args, **kwargs) + + def check_denylist(self): + """ + Checks if this token is present in the token denylist. Raises + `TokenError` if so. + """ + jti = self.payload[api_settings.JTI_CLAIM] + + if DenylistedToken.objects.filter(token__jti=jti).exists(): + raise TokenError(_('Token is denylisted')) + + def denylist(self): + """ + Ensures this token is included in the outstanding token list and + adds it to the denylist. + """ + jti = self.payload[api_settings.JTI_CLAIM] + exp = self.payload['exp'] + + # Ensure outstanding token exists with given jti + token, _ = OutstandingToken.objects.get_or_create( + jti=jti, + defaults={ + 'token': str(self), + 'expires_at': datetime_from_epoch(exp), + }, + ) + + return DenylistedToken.objects.get_or_create(token=token) + + @classmethod + def for_user(cls, user): + """ + Adds this token to the outstanding token list. + """ + token = super().for_user(user) + + jti = token[api_settings.JTI_CLAIM] + exp = token['exp'] + + OutstandingToken.objects.create( + user=user, + jti=jti, + token=str(token), + created_at=token.current_time, + expires_at=datetime_from_epoch(exp), + ) + + return token + + +class RefreshToken(DenylistMixin, Token): + token_type = 'refresh' + lifetime = api_settings.REFRESH_TOKEN_LIFETIME + no_copy_claims = ( + api_settings.TOKEN_TYPE_CLAIM, + 'exp', + + # Both of these claims are included even though they may be the same. + # It seems possible that a third party token might have a custom or + # namespaced JTI claim as well as a default "jti" claim. In that case, + # we wouldn't want to copy either one. + api_settings.JTI_CLAIM, + 'jti', + ) + + @property + def access_token(self): + """ + Returns an access token created from this refresh token. Copies all + claims present in this refresh token to the new access token except + those claims listed in the `no_copy_claims` attribute. + """ + access = AccessToken() + + # Use instantiation time of refresh token as relative timestamp for + # access token "exp" claim. This ensures that both a refresh and + # access token expire relative to the same time if they are created as + # a pair. + access.set_exp(from_time=self.current_time) + + no_copy = self.no_copy_claims + for claim, value in self.payload.items(): + if claim in no_copy: + continue + access[claim] = value + + return access + + +class AccessToken(Token): + token_type = 'access' + lifetime = api_settings.ACCESS_TOKEN_LIFETIME + + +class CancelToken(Token): + token_type = 'cancel_account' + lifetime = timedelta(days=365) + + +class UntypedToken(Token): + token_type = 'untyped' + lifetime = timedelta(seconds=0) + + def verify_token_type(self): + """ + Untyped tokens do not verify the "token_type" claim. This is useful + when performing general validation of a token's signature and other + properties which do not relate to the token's intended use. + """ + pass diff --git a/taiga/auth/utils.py b/taiga/auth/utils.py new file mode 100644 index 000000000..f7a46d31d --- /dev/null +++ b/taiga/auth/utils.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from calendar import timegm +from datetime import datetime + +from django.conf import settings +from django.utils.functional import lazy +from django.utils.timezone import is_naive, make_aware, utc + + +def make_utc(dt): + if settings.USE_TZ and is_naive(dt): + return make_aware(dt, timezone=utc) + + return dt + + +def aware_utcnow(): + return make_utc(datetime.utcnow()) + + +def datetime_to_epoch(dt): + return timegm(dt.utctimetuple()) + + +def datetime_from_epoch(ts): + return make_utc(datetime.utcfromtimestamp(ts)) + + +def format_lazy(s, *args, **kwargs): + return s.format(*args, **kwargs) + + +format_lazy = lazy(format_lazy, str) diff --git a/taiga/base/__init__.py b/taiga/base/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/api/__init__.py b/taiga/base/api/__init__.py new file mode 100644 index 000000000..201defda3 --- /dev/null +++ b/taiga/base/api/__init__.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +VERSION = "2.3.13-taiga" # Based on django-resframework 2.3.13 + +# Header encoding (see RFC5987) +HTTP_HEADER_ENCODING = 'iso-8859-1' + +# Default datetime input and output formats +ISO_8601 = 'iso-8601' + + +from .viewsets import ModelListViewSet +from .viewsets import ModelCrudViewSet +from .viewsets import ModelUpdateRetrieveViewSet +from .viewsets import GenericViewSet +from .viewsets import ReadOnlyListViewSet +from .viewsets import ModelRetrieveViewSet + +__all__ = ["ModelCrudViewSet", + "ModelListViewSet", + "ModelUpdateRetrieveViewSet", + "GenericViewSet", + "ReadOnlyListViewSet", + "ModelRetrieveViewSet"] diff --git a/taiga/base/api/authentication.py b/taiga/base/api/authentication.py new file mode 100644 index 000000000..3af69ebb4 --- /dev/null +++ b/taiga/base/api/authentication.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Provides various authentication policies. +""" +import base64 + +from django.contrib.auth import authenticate +from django.middleware.csrf import CsrfViewMiddleware + +from taiga.base import exceptions + +from . import HTTP_HEADER_ENCODING + + +def get_authorization_header(request): + """ + Return request's 'Authorization:' header, as a bytestring. + + Hide some test client ickyness where the header can be unicode. + """ + auth = request.headers.get('authorization', b'') + if type(auth) == type(''): + # Work around django test client oddness + auth = auth.encode(HTTP_HEADER_ENCODING) + return auth + + +class CSRFCheck(CsrfViewMiddleware): + def _reject(self, request, reason): + # Return the failure reason instead of an HttpResponse + return reason + + +class BaseAuthentication(object): + """ + All authentication classes should extend BaseAuthentication. + """ + + def authenticate(self, request): + """ + Authenticate the request and return a two-tuple of (user, token). + """ + raise NotImplementedError(".authenticate() must be overridden.") + + def authenticate_header(self, request): + """ + Return a string to be used as the value of the `WWW-Authenticate` + header in a `401 Unauthenticated` response, or `None` if the + authentication scheme should return `403 Permission Denied` responses. + """ + pass + + +class BasicAuthentication(BaseAuthentication): + """ + HTTP Basic authentication against username/password. + """ + www_authenticate_realm = 'api' + + def authenticate(self, request): + """ + Returns a `User` if a correct username and password have been supplied + using HTTP Basic authentication. Otherwise returns `None`. + """ + auth = get_authorization_header(request).split() + + if not auth or auth[0].lower() != b'basic': + return None + + if len(auth) == 1: + msg = 'Invalid basic header. No credentials provided.' + raise exceptions.AuthenticationFailed(msg) + elif len(auth) > 2: + msg = 'Invalid basic header. Credentials string should not contain spaces.' + raise exceptions.AuthenticationFailed(msg) + + try: + auth_parts = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':') + except (TypeError, UnicodeDecodeError): + msg = 'Invalid basic header. Credentials not correctly base64 encoded' + raise exceptions.AuthenticationFailed(msg) + + userid, password = auth_parts[0], auth_parts[2] + return self.authenticate_credentials(userid, password) + + def authenticate_credentials(self, userid, password): + """ + Authenticate the userid and password against username and password. + """ + user = authenticate(username=userid, password=password) + if user is None or not user.is_active: + raise exceptions.AuthenticationFailed('Invalid username/password') + return (user, None) + + def authenticate_header(self, request): + return 'Basic realm="%s"' % self.www_authenticate_realm + + +class SessionAuthentication(BaseAuthentication): + """ + Use Django's session framework for authentication. + """ + + def authenticate(self, request): + """ + Returns a `User` if the request session currently has a logged in user. + Otherwise returns `None`. + """ + + # Get the underlying HttpRequest object + request = request._request + user = getattr(request, 'user', None) + + # Unauthenticated, CSRF validation not required + if not user or not user.is_active: + return None + + self.enforce_csrf(request) + + # CSRF passed with authenticated user + return (user, None) + + def enforce_csrf(self, request): + """ + Enforce CSRF validation for session based authentication. + """ + reason = CSRFCheck().process_view(request, None, (), {}) + if reason: + # CSRF failed, bail with explicit error message + raise exceptions.AuthenticationFailed('CSRF Failed: %s' % reason) diff --git a/taiga/base/api/fields.py b/taiga/base/api/fields.py new file mode 100644 index 000000000..b57cacb38 --- /dev/null +++ b/taiga/base/api/fields.py @@ -0,0 +1,1100 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Serializer fields perform validation on incoming data. + +They are very similar to Django's form fields. +""" +from django import forms +from django.conf import settings +from django.core import validators +from django.db.models.fields import BLANK_CHOICE_DASH +from django.forms import widgets +from django.http import QueryDict +import six +from django.utils import timezone +from django.utils.dateparse import parse_date +from django.utils.dateparse import parse_datetime +from django.utils.dateparse import parse_time +from django.utils.encoding import smart_str +from django.utils.encoding import force_str +from django.utils.encoding import is_protected_type +from django.utils.functional import Promise +from django.utils.translation import gettext +from django.utils.translation import gettext_lazy as _ + +from taiga.base.exceptions import ValidationError + +from . import ISO_8601 +from .settings import api_settings + +from collections import OrderedDict +from decimal import Decimal, DecimalException +import copy +import datetime +import inspect +import re +import warnings + + +def is_non_str_iterable(obj): + if (isinstance(obj, str) or + (isinstance(obj, Promise) and obj._delegate_text)): + return False + return hasattr(obj, "__iter__") + + +def is_simple_callable(obj): + """ + True if the object is a callable that takes no arguments. + """ + function = inspect.isfunction(obj) + method = inspect.ismethod(obj) + + if not (function or method): + return False + + args, _, _, defaults, _, _, _ = inspect.getfullargspec(obj) + len_args = len(args) if function else len(args) - 1 + len_defaults = len(defaults) if defaults else 0 + return len_args <= len_defaults + + +def get_component(obj, attr_name): + """ + Given an object, and an attribute name, + return that attribute on the object. + """ + if isinstance(obj, dict): + val = obj.get(attr_name) + else: + val = getattr(obj, attr_name) + + if is_simple_callable(val): + return val() + return val + + +def readable_datetime_formats(formats): + format = ", ".join(formats).replace(ISO_8601, + "YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HHMM|-HHMM|Z]") + return humanize_strptime(format) + + +def readable_date_formats(formats): + format = ", ".join(formats).replace(ISO_8601, "YYYY[-MM[-DD]]") + return humanize_strptime(format) + + +def readable_time_formats(formats): + format = ", ".join(formats).replace(ISO_8601, "hh:mm[:ss[.uuuuuu]]") + return humanize_strptime(format) + + +def humanize_strptime(format_string): + # Note that we're missing some of the locale specific mappings that + # don't really make sense. + mapping = { + "%Y": "YYYY", + "%y": "YY", + "%m": "MM", + "%b": "[Jan-Dec]", + "%B": "[January-December]", + "%d": "DD", + "%H": "hh", + "%I": "hh", # Requires '%p' to differentiate from '%H'. + "%M": "mm", + "%S": "ss", + "%f": "uuuuuu", + "%a": "[Mon-Sun]", + "%A": "[Monday-Sunday]", + "%p": "[AM|PM]", + "%z": "[+HHMM|-HHMM]" + } + for key, val in mapping.items(): + format_string = format_string.replace(key, val) + return format_string + + +class Field(object): + read_only = True + i18n = False + creation_counter = 0 + empty = "" + type_name = None + partial = False + use_files = False + form_field_class = forms.CharField + type_label = "field" + widget = None + + def __init__(self, source=None, label=None, help_text=None, i18n=False): + self.parent = None + + self.creation_counter = Field.creation_counter + Field.creation_counter += 1 + + self.source = source + + if label is not None: + self.label = smart_str(label) + else: + self.label = None + + self.help_text = help_text + self._errors = [] + self._value = None + self._name = None + self.i18n = i18n + + @property + def errors(self): + return self._errors + + def widget_html(self): + if not self.widget: + return "" + return self.widget.render(self._name, self._value) + + def label_tag(self): + return "" % (self._name, self.label) + + def initialize(self, parent, field_name): + """ + Called to set up a field prior to field_to_native or field_from_native. + + parent - The parent serializer. + model_field - The model field this field corresponds to, if one exists. + """ + self.parent = parent + self.root = parent.root or parent + self.context = self.root.context + self.partial = self.root.partial + if self.partial: + self.required = False + + def field_from_native(self, data, files, field_name, into): + """ + Given a dictionary and a field name, updates the dictionary `into`, + with the field and it's deserialized value. + """ + return + + def field_to_native(self, obj, field_name): + """ + Given and object and a field name, returns the value that should be + serialized for that field. + """ + if obj is None: + return self.empty + + if self.source == "*": + return self.to_native(obj) + + source = self.source or field_name + value = obj + + for component in source.split("."): + value = get_component(value, component) + if value is None: + break + + return self.to_native(value) + + def to_native(self, value): + """ + Converts the field's value into it's simple representation. + """ + if is_simple_callable(value): + value = value() + + if is_protected_type(value): + return value + elif (is_non_str_iterable(value) and + not isinstance(value, (dict, six.string_types))): + return [self.to_native(item) for item in value] + elif isinstance(value, dict): + # Make sure we preserve field ordering, if it exists + ret = OrderedDict() + for key, val in value.items(): + ret[key] = self.to_native(val) + return ret + return force_str(value) + + def attributes(self): + """ + Returns a dictionary of attributes to be used when serializing to xml. + """ + if self.type_name: + return {"type": self.type_name} + return {} + + def metadata(self): + metadata = OrderedDict() + metadata["type"] = self.type_label + metadata["required"] = getattr(self, "required", False) + optional_attrs = ["read_only", "label", "help_text", + "min_length", "max_length"] + for attr in optional_attrs: + value = getattr(self, attr, None) + if value is not None and value != "": + metadata[attr] = force_str(value, strings_only=True) + return metadata + + +class WritableField(Field): + """ + Base for read/write fields. + """ + write_only = False + default_validators = [] + default_error_messages = { + "required": _("This field is required."), + "invalid": _("Invalid value."), + } + widget = widgets.TextInput + default = None + + def __init__(self, source=None, label=None, help_text=None, + read_only=False, write_only=False, required=None, + validators=[], error_messages=None, widget=None, + default=None, blank=None, i18n=False): + + # "blank" is to be deprecated in favor of "required" + if blank is not None: + warnings.warn("The `blank` keyword argument is deprecated. " + "Use the `required` keyword argument instead.", + DeprecationWarning, stacklevel=2) + required = not(blank) + + super(WritableField, self).__init__(source=source, label=label, + help_text=help_text, i18n=i18n) + + self.read_only = read_only + self.write_only = write_only + + assert not (read_only and write_only), "Cannot set read_only=True and write_only=True" + + if required is None: + self.required = not(read_only) + else: + assert not (read_only and required), "Cannot set required=True and read_only=True" + self.required = required + + messages = {} + for c in reversed(self.__class__.__mro__): + messages.update(getattr(c, "default_error_messages", {})) + messages.update(error_messages or {}) + self.error_messages = messages + + self.validators = self.default_validators + validators + self.default = default if default is not None else self.default + + # Widgets are ony used for HTML forms. + widget = widget or self.widget + if isinstance(widget, type): + widget = widget() + self.widget = widget + + def __deepcopy__(self, memo): + result = copy.copy(self) + memo[id(self)] = result + result.validators = self.validators[:] + return result + + def get_default_value(self): + if is_simple_callable(self.default): + return self.default() + return self.default + + def validate(self, value): + if value in validators.EMPTY_VALUES and self.required: + raise ValidationError(self.error_messages["required"]) + + def run_validators(self, value): + if value in validators.EMPTY_VALUES: + return + errors = [] + for v in self.validators: + try: + v(value) + except ValidationError as e: + if hasattr(e, "code") and e.code in self.error_messages: + message = self.error_messages[e.code] + if e.params: + message = message % e.params + errors.append(message) + else: + errors.extend(e.messages) + if errors: + raise ValidationError(errors) + + def field_to_native(self, obj, field_name): + if self.write_only: + return None + return super(WritableField, self).field_to_native(obj, field_name) + + def field_from_native(self, data, files, field_name, into): + """ + Given a dictionary and a field name, updates the dictionary `into`, + with the field and it's deserialized value. + """ + if self.read_only: + return + + try: + data = data or {} + if self.use_files: + files = files or {} + try: + native = files[field_name] + except KeyError: + native = data[field_name] + else: + native = data[field_name] + except KeyError: + if self.default is not None and not self.partial: + # Note: partial updates shouldn't set defaults + native = self.get_default_value() + else: + if self.required: + raise ValidationError(self.error_messages["required"]) + return + + value = self.from_native(native) + if self.source == "*": + if value: + into.update(value) + else: + self.validate(value) + self.run_validators(value) + into[self.source or field_name] = value + + def from_native(self, value): + """ + Reverts a simple representation back to the field's value. + """ + return value + + +class ModelField(WritableField): + """ + A generic field that can be used against an arbitrary model field. + """ + def __init__(self, *args, **kwargs): + try: + self.model_field = kwargs.pop("model_field") + except KeyError: + raise ValueError("ModelField requires 'model_field' kwarg") + + self.min_length = kwargs.pop("min_length", + getattr(self.model_field, "min_length", None)) + self.max_length = kwargs.pop("max_length", + getattr(self.model_field, "max_length", None)) + self.min_value = kwargs.pop("min_value", + getattr(self.model_field, "min_value", None)) + self.max_value = kwargs.pop("max_value", + getattr(self.model_field, "max_value", None)) + + super(ModelField, self).__init__(*args, **kwargs) + + if self.min_length is not None: + self.validators.append(validators.MinLengthValidator(self.min_length)) + if self.max_length is not None: + self.validators.append(validators.MaxLengthValidator(self.max_length)) + if self.min_value is not None: + self.validators.append(validators.MinValueValidator(self.min_value)) + if self.max_value is not None: + self.validators.append(validators.MaxValueValidator(self.max_value)) + + def from_native(self, value): + rel = getattr(self.model_field, "remote_field", None) + if rel is not None: + return rel.to._meta.get_field(rel.field_name).to_python(value) + else: + return self.model_field.to_python(value) + + def field_to_native(self, obj, field_name): + value = self.model_field.value_from_object(obj) + if is_protected_type(value): + return value + return self.model_field.value_to_string(obj) + + def attributes(self): + return { + "type": self.model_field.get_internal_type() + } + + def validate(self, value): + super(ModelField, self).validate(value) + if value is None and not self.model_field.null: + raise ValidationError(self.error_messages['invalid']) + + +##### Typed Fields ##### + +class BooleanField(WritableField): + type_name = "BooleanField" + type_label = "boolean" + form_field_class = forms.BooleanField + widget = widgets.CheckboxInput + default_error_messages = { + "invalid": _("'%s' value must be either True or False."), + } + empty = False + + def field_from_native(self, data, files, field_name, into): + # HTML checkboxes do not explicitly represent unchecked as `False` + # we deal with that here... + if isinstance(data, QueryDict) and self.default is None: + self.default = False + + return super(BooleanField, self).field_from_native( + data, files, field_name, into + ) + + def from_native(self, value): + if value in ("true", "t", "True", "1"): + return True + if value in ("false", "f", "False", "0"): + return False + return bool(value) + + +class CharField(WritableField): + type_name = "CharField" + type_label = "string" + form_field_class = forms.CharField + + def __init__(self, max_length=None, min_length=None, *args, **kwargs): + self.max_length, self.min_length = max_length, min_length + super(CharField, self).__init__(*args, **kwargs) + if min_length is not None: + self.validators.append(validators.MinLengthValidator(min_length)) + if max_length is not None: + self.validators.append(validators.MaxLengthValidator(max_length)) + + def from_native(self, value): + if value in validators.EMPTY_VALUES: + return "" + + return smart_str(value) + + def to_native(self, value): + ret = super(CharField, self).to_native(value) + if self.i18n: + ret = gettext(ret) + + return ret + + +class URLField(CharField): + type_name = "URLField" + type_label = "url" + + def __init__(self, **kwargs): + if not "validators" in kwargs: + kwargs["validators"] = [validators.URLValidator()] + super(URLField, self).__init__(**kwargs) + + +class SlugField(CharField): + type_name = "SlugField" + type_label = "slug" + form_field_class = forms.SlugField + + default_error_messages = { + "invalid": _("Enter a valid 'slug' consisting of letters, numbers," + " underscores or hyphens."), + } + default_validators = [validators.validate_slug] + + def __init__(self, *args, **kwargs): + super(SlugField, self).__init__(*args, **kwargs) + + +class ChoiceField(WritableField): + type_name = "ChoiceField" + type_label = "multiple choice" + form_field_class = forms.ChoiceField + widget = widgets.Select + default_error_messages = { + "invalid_choice": _("Select a valid choice. %(value)s is not one of " + "the available choices."), + } + + def __init__(self, choices=(), *args, **kwargs): + self.empty = kwargs.pop("empty", "") + super(ChoiceField, self).__init__(*args, **kwargs) + self.choices = choices + if not self.required: + self.choices = BLANK_CHOICE_DASH + self.choices + + def _get_choices(self): + return self._choices + + def _set_choices(self, value): + # Setting choices also sets the choices on the widget. + # choices can be any iterable, but we call list() on it because + # it will be consumed more than once. + self._choices = self.widget.choices = list(value) + + choices = property(_get_choices, _set_choices) + + def metadata(self): + data = super(ChoiceField, self).metadata() + data["choices"] = [{"value": v, "display_name": n} for v, n in self.choices] + return data + + def validate(self, value): + """ + Validates that the input is in self.choices. + """ + super(ChoiceField, self).validate(value) + if value and not self.valid_value(value): + raise ValidationError(self.error_messages["invalid_choice"] % {"value": value}) + + def valid_value(self, value): + """ + Check to see if the provided value is a valid choice. + """ + for k, v in self.choices: + if isinstance(v, (list, tuple)): + # This is an optgroup, so look inside the group for options + for k2, v2 in v: + if value == smart_str(k2): + return True + else: + if value == smart_str(k) or value == k: + return True + return False + + def from_native(self, value): + value = super(ChoiceField, self).from_native(value) + if value == self.empty or value in validators.EMPTY_VALUES: + return self.empty + return value + + +class InvalidEmailValidationError(ValidationError): + pass + + +class InvalidDomainValidationError(ValidationError): + pass + + +def validate_user_email_allowed_domains(value): + try: + validators.validate_email(value) + except ValidationError as e: + raise InvalidEmailValidationError(e) + + domain_name = value.split("@")[1] + + if settings.USER_EMAIL_ALLOWED_DOMAINS and domain_name not in settings.USER_EMAIL_ALLOWED_DOMAINS: + raise InvalidDomainValidationError(_("You email domain is not allowed")) + + +class EmailField(CharField): + type_name = "EmailField" + type_label = "email" + form_field_class = forms.EmailField + + default_error_messages = { + "invalid": _("Enter a valid email address."), + } + default_validators = [validate_user_email_allowed_domains] + + def from_native(self, value): + ret = super(EmailField, self).from_native(value) + if ret is None: + return None + return ret.strip() + + +class RegexField(CharField): + type_name = "RegexField" + type_label = "regex" + form_field_class = forms.RegexField + + def __init__(self, regex, max_length=None, min_length=None, *args, **kwargs): + super(RegexField, self).__init__(max_length, min_length, *args, **kwargs) + self.regex = regex + + def _get_regex(self): + return self._regex + + def _set_regex(self, regex): + if isinstance(regex, six.string_types): + regex = re.compile(regex) + self._regex = regex + if hasattr(self, "_regex_validator") and self._regex_validator in self.validators: + self.validators.remove(self._regex_validator) + self._regex_validator = validators.RegexValidator(regex=regex) + self.validators.append(self._regex_validator) + + regex = property(_get_regex, _set_regex) + + +class DateField(WritableField): + type_name = "DateField" + type_label = "date" + widget = widgets.DateInput + form_field_class = forms.DateField + + default_error_messages = { + "invalid": _("Date has wrong format. Use one of these formats instead: %s"), + } + empty = None + input_formats = api_settings.DATE_INPUT_FORMATS + format = api_settings.DATE_FORMAT + + def __init__(self, input_formats=None, format=None, *args, **kwargs): + self.input_formats = input_formats if input_formats is not None else self.input_formats + self.format = format if format is not None else self.format + super(DateField, self).__init__(*args, **kwargs) + + def from_native(self, value): + if value in validators.EMPTY_VALUES: + return None + + if isinstance(value, datetime.datetime): + if timezone and settings.USE_TZ and timezone.is_aware(value): + # Convert aware datetimes to the default time zone + # before casting them to dates (#17742). + default_timezone = timezone.get_default_timezone() + value = timezone.make_naive(value, default_timezone) + return value.date() + if isinstance(value, datetime.date): + return value + + for format in self.input_formats: + if format.lower() == ISO_8601: + try: + parsed = parse_date(value) + except (ValueError, TypeError): + pass + else: + if parsed is not None: + return parsed + else: + try: + parsed = datetime.datetime.strptime(value, format) + except (ValueError, TypeError): + pass + else: + return parsed.date() + + msg = self.error_messages["invalid"] % readable_date_formats(self.input_formats) + raise ValidationError(msg) + + def to_native(self, value): + if value is None or self.format is None: + return value + + if isinstance(value, datetime.datetime): + value = value.date() + + if self.format.lower() == ISO_8601: + return value.isoformat() + return value.strftime(self.format) + + +class DateTimeField(WritableField): + type_name = "DateTimeField" + type_label = "datetime" + widget = widgets.DateTimeInput + form_field_class = forms.DateTimeField + + default_error_messages = { + "invalid": _("Datetime has wrong format. Use one of these formats instead: %s"), + } + empty = None + input_formats = api_settings.DATETIME_INPUT_FORMATS + format = api_settings.DATETIME_FORMAT + + def __init__(self, input_formats=None, format=None, *args, **kwargs): + self.input_formats = input_formats if input_formats is not None else self.input_formats + self.format = format if format is not None else self.format + super(DateTimeField, self).__init__(*args, **kwargs) + + def from_native(self, value): + if value in validators.EMPTY_VALUES: + return None + + if isinstance(value, datetime.datetime): + return value + if isinstance(value, datetime.date): + value = datetime.datetime(value.year, value.month, value.day) + if settings.USE_TZ: + # For backwards compatibility, interpret naive datetimes in + # local time. This won't work during DST change, but we can"t + # do much about it, so we let the exceptions percolate up the + # call stack. + warnings.warn("DateTimeField received a naive datetime (%s)" + " while time zone support is active." % value, + RuntimeWarning) + default_timezone = timezone.get_default_timezone() + value = timezone.make_aware(value, default_timezone) + return value + + for format in self.input_formats: + if format.lower() == ISO_8601: + try: + parsed = parse_datetime(value) + except (ValueError, TypeError): + pass + else: + if parsed is not None: + return parsed + else: + try: + parsed = datetime.datetime.strptime(value, format) + except (ValueError, TypeError): + pass + else: + return parsed + + msg = self.error_messages["invalid"] % readable_datetime_formats(self.input_formats) + raise ValidationError(msg) + + def to_native(self, value): + if value is None or self.format is None: + return value + + if self.format.lower() == ISO_8601: + ret = value.isoformat() + if ret.endswith("+00:00"): + ret = ret[:-6] + "Z" + return ret + return value.strftime(self.format) + + +class TimeField(WritableField): + type_name = "TimeField" + type_label = "time" + widget = widgets.TimeInput + form_field_class = forms.TimeField + + default_error_messages = { + "invalid": _("Time has wrong format. Use one of these formats instead: %s"), + } + empty = None + input_formats = api_settings.TIME_INPUT_FORMATS + format = api_settings.TIME_FORMAT + + def __init__(self, input_formats=None, format=None, *args, **kwargs): + self.input_formats = input_formats if input_formats is not None else self.input_formats + self.format = format if format is not None else self.format + super(TimeField, self).__init__(*args, **kwargs) + + def from_native(self, value): + if value in validators.EMPTY_VALUES: + return None + + if isinstance(value, datetime.time): + return value + + for format in self.input_formats: + if format.lower() == ISO_8601: + try: + parsed = parse_time(value) + except (ValueError, TypeError): + pass + else: + if parsed is not None: + return parsed + else: + try: + parsed = datetime.datetime.strptime(value, format) + except (ValueError, TypeError): + pass + else: + return parsed.time() + + msg = self.error_messages["invalid"] % readable_time_formats(self.input_formats) + raise ValidationError(msg) + + def to_native(self, value): + if value is None or self.format is None: + return value + + if isinstance(value, datetime.datetime): + value = value.time() + + if self.format.lower() == ISO_8601: + return value.isoformat() + return value.strftime(self.format) + + +class IntegerField(WritableField): + type_name = "IntegerField" + type_label = "integer" + form_field_class = forms.IntegerField + empty = 0 + + default_error_messages = { + "invalid": _("Enter a whole number."), + "max_value": _("Ensure this value is less than or equal to %(limit_value)s."), + "min_value": _("Ensure this value is greater than or equal to %(limit_value)s."), + } + + def __init__(self, max_value=None, min_value=None, *args, **kwargs): + self.max_value, self.min_value = max_value, min_value + super(IntegerField, self).__init__(*args, **kwargs) + + if max_value is not None: + self.validators.append(validators.MaxValueValidator(max_value)) + if min_value is not None: + self.validators.append(validators.MinValueValidator(min_value)) + + def from_native(self, value): + if value in validators.EMPTY_VALUES: + return None + + try: + value = int(str(value)) + except (ValueError, TypeError): + raise ValidationError(self.error_messages["invalid"]) + return value + + +class FloatField(WritableField): + type_name = "FloatField" + type_label = "float" + form_field_class = forms.FloatField + empty = 0 + + default_error_messages = { + "invalid": _('"%s" value must be a float.'), + } + + def from_native(self, value): + if value in validators.EMPTY_VALUES: + return None + + try: + return float(value) + except (TypeError, ValueError): + msg = self.error_messages["invalid"] % value + raise ValidationError(msg) + + +class DecimalField(WritableField): + type_name = "DecimalField" + type_label = "decimal" + form_field_class = forms.DecimalField + empty = Decimal("0") + + default_error_messages = { + "invalid": _("Enter a number."), + "max_value": _("Ensure this value is less than or equal to %(limit_value)s."), + "min_value": _("Ensure this value is greater than or equal to %(limit_value)s."), + "max_digits": _("Ensure that there are no more than %s digits in total."), + "max_decimal_places": _("Ensure that there are no more than %s decimal places."), + "max_whole_digits": _("Ensure that there are no more than %s digits before the decimal point.") + } + + def __init__(self, max_value=None, min_value=None, max_digits=None, decimal_places=None, *args, **kwargs): + self.max_value, self.min_value = max_value, min_value + self.max_digits, self.decimal_places = max_digits, decimal_places + super(DecimalField, self).__init__(*args, **kwargs) + + if max_value is not None: + self.validators.append(validators.MaxValueValidator(max_value)) + if min_value is not None: + self.validators.append(validators.MinValueValidator(min_value)) + + def from_native(self, value): + """ + Validates that the input is a decimal number. Returns a Decimal + instance. Returns None for empty values. Ensures that there are no more + than max_digits in the number, and no more than decimal_places digits + after the decimal point. + """ + if value in validators.EMPTY_VALUES: + return None + value = smart_str(value).strip() + try: + value = Decimal(value) + except DecimalException: + raise ValidationError(self.error_messages["invalid"]) + return value + + def validate(self, value): + super(DecimalField, self).validate(value) + if value in validators.EMPTY_VALUES: + return + # Check for NaN, Inf and -Inf values. We can't compare directly for NaN, + # since it is never equal to itself. However, NaN is the only value that + # isn't equal to itself, so we can use this to identify NaN + if value != value or value == Decimal("Inf") or value == Decimal("-Inf"): + raise ValidationError(self.error_messages["invalid"]) + sign, digittuple, exponent = value.as_tuple() + decimals = abs(exponent) + # digittuple doesn't include any leading zeros. + digits = len(digittuple) + if decimals > digits: + # We have leading zeros up to or past the decimal point. Count + # everything past the decimal point as a digit. We do not count + # 0 before the decimal point as a digit since that would mean + # we would not allow max_digits = decimal_places. + digits = decimals + whole_digits = digits - decimals + + if self.max_digits is not None and digits > self.max_digits: + raise ValidationError(self.error_messages["max_digits"] % self.max_digits) + if self.decimal_places is not None and decimals > self.decimal_places: + raise ValidationError(self.error_messages["max_decimal_places"] % self.decimal_places) + if self.max_digits is not None and self.decimal_places is not None and whole_digits > (self.max_digits - self.decimal_places): + raise ValidationError(self.error_messages["max_whole_digits"] % (self.max_digits - self.decimal_places)) + return value + + +class FileField(WritableField): + use_files = True + type_name = "FileField" + type_label = "file upload" + form_field_class = forms.FileField + widget = widgets.FileInput + + default_error_messages = { + "invalid": _("No file was submitted. Check the encoding type on the form."), + "missing": _("No file was submitted."), + "empty": _("The submitted file is empty."), + "max_length": _("Ensure this filename has at most %(max)d characters (it has %(length)d)."), + "contradiction": _("Please either submit a file or check the clear checkbox, not both.") + } + + def __init__(self, *args, **kwargs): + self.max_length = kwargs.pop("max_length", None) + self.allow_empty_file = kwargs.pop("allow_empty_file", False) + super(FileField, self).__init__(*args, **kwargs) + + def from_native(self, data): + if data in validators.EMPTY_VALUES: + return None + + # UploadedFile objects should have name and size attributes. + try: + file_name = data.name + file_size = data.size + except AttributeError: + raise ValidationError(self.error_messages["invalid"]) + + if self.max_length is not None and len(file_name) > self.max_length: + error_values = {"max": self.max_length, "length": len(file_name)} + raise ValidationError(self.error_messages["max_length"] % error_values) + if not file_name: + raise ValidationError(self.error_messages["invalid"]) + if not self.allow_empty_file and not file_size: + raise ValidationError(self.error_messages["empty"]) + + return data + + def to_native(self, value): + return value.name + + +class ImageField(FileField): + use_files = True + type_name = "ImageField" + type_label = "image upload" + form_field_class = forms.ImageField + + default_error_messages = { + "invalid_image": _("Upload a valid image. The file you uploaded was " + "either not an image or a corrupted image."), + } + + def from_native(self, data): + """ + Checks that the file-upload field data contains a valid image (GIF, JPG, + PNG, possibly others -- whatever the Python Imaging Library supports). + """ + f = super(ImageField, self).from_native(data) + if f is None: + return None + + # Try to import PIL in either of the two ways it can end up installed. + try: + from PIL import Image + except ImportError: + try: + import Image + except ImportError: + Image = None + + assert Image is not None, "Either Pillow or PIL must be installed for ImageField support." + + # We need to get a file object for PIL. We might have a path or we might + # have to read the data into memory. + if hasattr(data, "temporary_file_path"): + file = data.temporary_file_path() + else: + if hasattr(data, "read"): + file = six.BytesIO(data.read()) + else: + file = six.BytesIO(data["content"]) + + try: + # load() could spot a truncated JPEG, but it loads the entire + # image in memory, which is a DoS vector. See #3848 and #18520. + # verify() must be called immediately after the constructor. + Image.open(file).verify() + except ImportError: + # Under PyPy, it is possible to import PIL. However, the underlying + # _imaging C module isn't available, so an ImportError will be + # raised. Catch and re-raise. + raise + except Exception: # Python Imaging Library doesn't recognize it as an image + raise ValidationError(self.error_messages["invalid_image"]) + if hasattr(f, "seek") and callable(f.seek): + f.seek(0) + return f + + +class SerializerMethodField(Field): + """ + A field that gets its value by calling a method on the serializer it's attached to. + """ + + def __init__(self, method_name): + self.method_name = method_name + super(SerializerMethodField, self).__init__() + + def field_to_native(self, obj, field_name): + value = getattr(self.parent, self.method_name)(obj) + return self.to_native(value) diff --git a/taiga/base/api/generics.py b/taiga/base/api/generics.py new file mode 100644 index 000000000..9433b1144 --- /dev/null +++ b/taiga/base/api/generics.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404 + +from . import views +from . import mixins +from . import pagination +from .settings import api_settings +from .utils import get_object_or_error + + +class GenericAPIView(pagination.PaginationMixin, + views.APIView): + """ + Base class for all other generic views. + """ + + # You'll need to either set these attributes, + # or override `get_queryset()`/`get_serializer_class()`. + queryset = None + serializer_class = None + validator_class = None + + # This shortcut may be used instead of setting either or both + # of the `queryset`/`serializer_class` attributes, although using + # the explicit style is generally preferred. + model = None + + # If you want to use object lookups other than pk, set this attribute. + # For more complex lookup requirements override `get_object()`. + lookup_field = 'pk' + lookup_url_kwarg = None + + # The filter backend classes to use for queryset filtering + filter_backends = api_settings.DEFAULT_FILTER_BACKENDS + + # The following attributes may be subject to change, + # and should be considered private API. + model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS + model_validator_class = api_settings.DEFAULT_MODEL_VALIDATOR_CLASS + + ###################################### + # These are pending deprecation... + + pk_url_kwarg = 'pk' + slug_url_kwarg = 'slug' + slug_field = 'slug' + allow_empty = True + + def get_extra_context(self): + """ + Extra context provided to the serializer class. + """ + return { + 'request': self.request, + 'format': self.format_kwarg, + 'view': self + } + + def get_serializer(self, instance=None, data=None, + files=None, many=False, partial=False): + """ + Return the serializer instance that should be used for deserializing + input, and for serializing output. + """ + serializer_class = self.get_serializer_class() + context = self.get_extra_context() + return serializer_class(instance, data=data, files=files, + many=many, partial=partial, context=context) + + def get_validator(self, instance=None, data=None, + files=None, many=False, partial=False): + """ + Return the validator instance that should be used for validating the + input, and for serializing output. + """ + validator_class = self.get_validator_class() + context = self.get_extra_context() + return validator_class(instance, data=data, files=files, + many=many, partial=partial, context=context) + + def filter_queryset(self, queryset, filter_backends=None): + """ + Given a queryset, filter it with whichever filter backend is in use. + + You are unlikely to want to override this method, although you may need + to call it either from a list view, or from a custom `get_object` + method if you want to apply the configured filtering backend to the + default queryset. + """ + # NOTE TAIGA: Added filter_backends to overwrite the default behavior. + + backends = filter_backends or self.get_filter_backends() + for backend in backends: + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def get_filter_backends(self): + """ + Returns the list of filter backends that this view requires. + """ + filter_backends = self.filter_backends or [] + if not filter_backends and hasattr(self, 'filter_backend'): + raise RuntimeError('The `filter_backend` attribute and `FILTER_BACKEND` setting ' + 'are due to be deprecated in favor of a `filter_backends` ' + 'attribute and `DEFAULT_FILTER_BACKENDS` setting, that take ' + 'a *list* of filter backend classes.') + return filter_backends + + ########################################################### + # The following methods provide default implementations # + # that you may want to override for more complex cases. # + ########################################################### + + def get_serializer_class(self): + if hasattr(self, "action") and self.action == "list" and hasattr(self, "list_serializer_class"): + return self.list_serializer_class + + serializer_class = self.serializer_class + if serializer_class is not None: + return serializer_class + + assert self.model is not None, ("'%s' should either include a 'serializer_class' attribute, " + "or use the 'model' attribute as a shortcut for " + "automatically generating a serializer class." % self.__class__.__name__) + + class DefaultSerializer(self.model_serializer_class): + class Meta: + model = self.model + return DefaultSerializer + + def get_validator_class(self): + validator_class = self.validator_class + serializer_class = self.get_serializer_class() + + # Situations where the validator is the rest framework serializer + if validator_class is None and serializer_class is not None: + return serializer_class + + if validator_class is not None: + return validator_class + + class DefaultValidator(self.model_validator_class): + class Meta: + model = self.model + return DefaultValidator + + def get_queryset(self): + """ + Get the list of items for this view. + This must be an iterable, and may be a queryset. + Defaults to using `self.queryset`. + + You may want to override this if you need to provide different + querysets depending on the incoming request. + + (Eg. return a list of items that is specific to the user) + """ + if self.queryset is not None: + return self.queryset._clone() + + if self.model is not None: + return self.model._default_manager.all() + + raise ImproperlyConfigured(("'%s' must define 'queryset' or 'model'" % self.__class__.__name__)) + + def get_object(self, queryset=None): + """ + Returns the object the view is displaying. + + You may want to override this if you need to provide non-standard + queryset lookups. Eg if objects are referenced using multiple + keyword arguments in the url conf. + """ + # Determine the base queryset to use. + if queryset is None: + queryset = self.filter_queryset(self.get_queryset()) + else: + # NOTE: explicit exception for avoid and fix + # usage of deprecated way of get_object + raise RuntimeError("DEPRECATED") + + # Perform the lookup filtering. + # Note that `pk` and `slug` are deprecated styles of lookup filtering. + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + lookup = self.kwargs.get(lookup_url_kwarg, None) + pk = self.kwargs.get(self.pk_url_kwarg, None) + slug = self.kwargs.get(self.slug_url_kwarg, None) + + if lookup is not None: + filter_kwargs = {self.lookup_field: lookup} + elif pk is not None and self.lookup_field == 'pk': + raise RuntimeError(('The `pk_url_kwarg` attribute is due to be deprecated. ' + 'Use the `lookup_field` attribute instead')) + elif slug is not None and self.lookup_field == 'pk': + raise RuntimeError(('The `slug_url_kwarg` attribute is due to be deprecated. ' + 'Use the `lookup_field` attribute instead')) + else: + raise ImproperlyConfigured(('Expected view %s to be called with a URL keyword argument ' + 'named "%s". Fix your URL conf, or set the `.lookup_field` ' + 'attribute on the view correctly.' % + (self.__class__.__name__, self.lookup_field))) + + obj = get_object_or_error(queryset, self.request.user, **filter_kwargs) + return obj + + def get_object_or_none(self): + try: + return self.get_object() + except Http404: + return None + + ################################################### + # The following are placeholder methods, # + # and are intended to be overridden. # + # # + # The are not called by GenericAPIView directly, # + # but are used by the mixin methods. # + ################################################### + + def pre_conditions_on_save(self, obj): + """ + Placeholder method called by mixins before save for check + some conditions before save. + """ + pass + + def pre_conditions_on_delete(self, obj): + """ + Placeholder method called by mixins before delete for check + some conditions before delete. + """ + pass + + def pre_save(self, obj): + """ + Placeholder method for calling before saving an object. + + May be used to set attributes on the object that are implicit + in either the request, or the url. + """ + pass + + def post_save(self, obj, created=False): + """ + Placeholder method for calling after saving an object. + """ + pass + + def pre_delete(self, obj): + """ + Placeholder method for calling before deleting an object. + """ + pass + + def post_delete(self, obj): + """ + Placeholder method for calling after deleting an object. + """ + pass + + +###################################################### +# Concrete view classes that provide method handlers # +# by composing the mixin classes with the base view. # +# NOTE: not used by taiga. # +###################################################### + +class CreateAPIView(mixins.CreateModelMixin, + GenericAPIView): + + """ + Concrete view for creating a model instance. + """ + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + +class ListAPIView(mixins.ListModelMixin, + GenericAPIView): + """ + Concrete view for listing a queryset. + """ + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class RetrieveAPIView(mixins.RetrieveModelMixin, + GenericAPIView): + """ + Concrete view for retrieving a model instance. + """ + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + +class DestroyAPIView(mixins.DestroyModelMixin, + GenericAPIView): + + """ + Concrete view for deleting a model instance. + """ + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + +class UpdateAPIView(mixins.UpdateModelMixin, + GenericAPIView): + + """ + Concrete view for updating a model instance. + """ + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + +class ListCreateAPIView(mixins.ListModelMixin, + mixins.CreateModelMixin, + GenericAPIView): + """ + Concrete view for listing a queryset or creating a model instance. + """ + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + +class RetrieveUpdateAPIView(mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + GenericAPIView): + """ + Concrete view for retrieving, updating a model instance. + """ + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + +class RetrieveDestroyAPIView(mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + GenericAPIView): + """ + Concrete view for retrieving or deleting a model instance. + """ + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + +class RetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + GenericAPIView): + """ + Concrete view for retrieving, updating or deleting a model instance. + """ + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) diff --git a/taiga/base/api/mixins.py b/taiga/base/api/mixins.py new file mode 100644 index 000000000..a51cfeb7d --- /dev/null +++ b/taiga/base/api/mixins.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import warnings + +from django.http import Http404 +from django.db import transaction as tx +from django.utils.translation import gettext as _ + +from taiga.base import response +from taiga.base.exceptions import ValidationError + +from .settings import api_settings +from .utils import get_object_or_404 + +from .. import exceptions as exc +from ..decorators import model_pk_lock + + +def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None): + """ + Given a model instance, and an optional pk and slug field, + return the full list of all other field names on that model. + + For use when performing full_clean on a model instance, + so we only clean the required fields. + """ + include = [] + + if pk: + # Pending deprecation + pk_field = obj._meta.pk + while pk_field.remote_field: + pk_field = pk_field.remote_field.model._meta.pk + include.append(pk_field.name) + + if slug_field: + # Pending deprecation + include.append(slug_field) + + if lookup_field and lookup_field != 'pk': + include.append(lookup_field) + + return [field.name for field in obj._meta.fields if field.name not in include] + + +class CreateModelMixin: + """ + Create a model instance. + """ + def create(self, request, *args, **kwargs): + validator = self.get_validator(data=request.DATA, files=request.FILES) + + if validator.is_valid(): + self.check_permissions(request, 'create', validator.object) + + self.pre_save(validator.object) + self.pre_conditions_on_save(validator.object) + self.object = validator.save(force_insert=True) + self.post_save(self.object, created=True) + instance = self.get_queryset().get(id=self.object.id) + serializer = self.get_serializer(instance) + headers = self.get_success_headers(serializer.data) + return response.Created(serializer.data, headers=headers) + + return response.BadRequest(validator.errors) + + def get_success_headers(self, data): + try: + return {'Location': data[api_settings.URL_FIELD_NAME]} + except (TypeError, KeyError): + return {} + + +class ListModelMixin: + """ + List a queryset. + """ + empty_error = "Empty list and '%(class_name)s.allow_empty' is False." + + def list(self, request, *args, **kwargs): + self.object_list = self.filter_queryset(self.get_queryset()) + + # Default is to allow empty querysets. This can be altered by setting + # `.allow_empty = False`, to raise 404 errors on empty querysets. + if not self.allow_empty and not self.object_list: + warnings.warn('The `allow_empty` parameter is due to be deprecated. ' + 'To use `allow_empty=False` style behavior, You should override ' + '`get_queryset()` and explicitly raise a 404 on empty querysets.', + PendingDeprecationWarning) + class_name = self.__class__.__name__ + error_msg = self.empty_error % {'class_name': class_name} + raise Http404(error_msg) + + # Switch between paginated or standard style responses + page = self.paginate_queryset(self.object_list) + if page is not None: + serializer = self.get_pagination_serializer(page) + else: + serializer = self.get_serializer(self.object_list, many=True) + + return response.Ok(serializer.data) + + +class RetrieveModelMixin: + """ + Retrieve a model instance. + """ + def retrieve(self, request, *args, **kwargs): + self.object = get_object_or_404(self.get_queryset(), **kwargs) + + self.check_permissions(request, 'retrieve', self.object) + + if self.object is None: + raise Http404 + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + +class UpdateModelMixin: + """ + Update a model instance. + """ + + @tx.atomic + @model_pk_lock + def update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + if not getattr(self, 'object', None): + self.object = self.get_object_or_none() + self.check_permissions(request, 'update', self.object) + + if self.object is None: + raise Http404 + + if hasattr(self, 'pre_validate'): + self.pre_validate() + + validator = self.get_validator(self.object, data=request.DATA, + files=request.FILES, partial=partial) + + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + # Hooks + try: + self.pre_save(validator.object) + self.pre_conditions_on_save(validator.object) + except ValidationError as err: + # full_clean on model instance may be called in pre_save, + # so we have to handle eventual errors. + return response.BadRequest(err.message_dict) + + self.object = validator.save(force_update=True) + self.post_save(self.object, created=False) + instance = self.get_queryset().get(id=self.object.id) + serializer = self.get_serializer(instance) + return response.Ok(serializer.data) + + def partial_update(self, request, *args, **kwargs): + kwargs['partial'] = True + return self.update(request, *args, **kwargs) + + def pre_save(self, obj): + """ + Set any attributes on the object that are implicit in the request. + """ + # pk and/or slug attributes are implicit in the URL. + ##lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + ##lookup = self.kwargs.get(lookup_url_kwarg, None) + pk = self.kwargs.get(self.pk_url_kwarg, None) + slug = self.kwargs.get(self.slug_url_kwarg, None) + slug_field = slug and self.slug_field or None + + ##if lookup: + ## setattr(obj, self.lookup_field, lookup) + + if pk: + setattr(obj, 'pk', pk) + + if slug: + setattr(obj, slug_field, slug) + + # Ensure we clean the attributes so that we don't eg return integer + # pk using a string representation, as provided by the url conf kwarg. + if hasattr(obj, 'full_clean'): + exclude = _get_validation_exclusions(obj, pk, slug_field, self.lookup_field) + obj.full_clean(exclude) + + +class DestroyModelMixin: + """ + Destroy a model instance. + """ + @tx.atomic + @model_pk_lock + def destroy(self, request, *args, **kwargs): + obj = self.get_object_or_none() + self.check_permissions(request, 'destroy', obj) + + if obj is None: + raise Http404 + + self.pre_delete(obj) + self.pre_conditions_on_delete(obj) + obj.delete() + self.post_delete(obj) + return response.NoContent() + + +class NestedViewSetMixin(object): + def get_queryset(self): + return self._filter_queryset_by_parents_lookups(super().get_queryset()) + + def _filter_queryset_by_parents_lookups(self, queryset): + parents_query_dict = self._get_parents_query_dict() + if parents_query_dict: + return queryset.filter(**parents_query_dict) + else: + return queryset + + def _get_parents_query_dict(self): + result = {} + for kwarg_name in self.kwargs: + query_value = self.kwargs.get(kwarg_name) + result[kwarg_name] = query_value + return result + + +## TODO: Move blocked mixind out of the base module because is related to project + +class BlockeableModelMixin: + def is_blocked(self, obj): + raise NotImplementedError("is_blocked must be overridden") + + def pre_conditions_blocked(self, obj): + # Raises permission exception + if obj is not None and self.is_blocked(obj): + raise exc.Blocked(_("Blocked element")) + + +class BlockeableSaveMixin(BlockeableModelMixin): + def pre_conditions_on_save(self, obj): + # Called on create and update calls + self.pre_conditions_blocked(obj) + super().pre_conditions_on_save(obj) + + +class BlockeableDeleteMixin(): + def pre_conditions_on_delete(self, obj): + # Called on destroy call + self.pre_conditions_blocked(obj) + super().pre_conditions_on_delete(obj) + + +class BlockedByProjectMixin(BlockeableSaveMixin, BlockeableDeleteMixin): + def is_blocked(self, obj): + return obj.project is not None and obj.project.blocked_code is not None diff --git a/taiga/base/api/negotiation.py b/taiga/base/api/negotiation.py new file mode 100644 index 000000000..284658340 --- /dev/null +++ b/taiga/base/api/negotiation.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Content negotiation deals with selecting an appropriate renderer given the +incoming request. Typically this will be based on the request's Accept header. +""" + +from django.http import Http404 + +from taiga.base import exceptions +from .settings import api_settings + +from .utils.mediatypes import order_by_precedence +from .utils.mediatypes import media_type_matches +from .utils.mediatypes import _MediaType + + +class BaseContentNegotiation(object): + def select_parser(self, request, parsers): + raise NotImplementedError(".select_parser() must be implemented") + + def select_renderer(self, request, renderers, format_suffix=None): + raise NotImplementedError(".select_renderer() must be implemented") + + +class DefaultContentNegotiation(BaseContentNegotiation): + settings = api_settings + + def select_parser(self, request, parsers): + """ + Given a list of parsers and a media type, return the appropriate + parser to handle the incoming request. + """ + for parser in parsers: + if media_type_matches(parser.media_type, request.content_type): + return parser + return None + + def select_renderer(self, request, renderers, format_suffix=None): + """ + Given a request and a list of renderers, return a two-tuple of: + (renderer, media type). + """ + # Allow URL style format override. eg. "?format=json + format_query_param = self.settings.URL_FORMAT_OVERRIDE + format = format_suffix or request.QUERY_PARAMS.get(format_query_param) + + if format: + renderers = self.filter_renderers(renderers, format) + + accepts = self.get_accept_list(request) + + # Check the acceptable media types against each renderer, + # attempting more specific media types first + # NB. The inner loop here isni't as bad as it first looks :) + # Worst case is we"re looping over len(accept_list) * len(self.renderers) + for media_type_set in order_by_precedence(accepts): + for renderer in renderers: + for media_type in media_type_set: + if media_type_matches(renderer.media_type, media_type): + # Return the most specific media type as accepted. + if (_MediaType(renderer.media_type).precedence > + _MediaType(media_type).precedence): + # Eg client requests "*/*" + # Accepted media type is "application/json" + return renderer, renderer.media_type + else: + # Eg client requests "application/json; indent=8" + # Accepted media type is "application/json; indent=8" + return renderer, media_type + + raise exceptions.NotAcceptable(available_renderers=renderers) + + def filter_renderers(self, renderers, format): + """ + If there is a ".json" style format suffix, filter the renderers + so that we only negotiation against those that accept that format. + """ + renderers = [renderer for renderer in renderers + if renderer.format == format] + if not renderers: + raise Http404 + return renderers + + def get_accept_list(self, request): + """ + Given the incoming request, return a tokenised list of media + type strings. + + Allows URL style accept override. eg. "?accept=application/json" + """ + header = request.headers.get("accept", "*/*") + header = request.QUERY_PARAMS.get(self.settings.URL_ACCEPT_OVERRIDE, header) + return [token.strip() for token in header.split(",")] diff --git a/taiga/base/api/pagination.py b/taiga/base/api/pagination.py new file mode 100644 index 000000000..38a8474b6 --- /dev/null +++ b/taiga/base/api/pagination.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.paginator import ( + EmptyPage, + Page, + PageNotAnInteger, + Paginator, + InvalidPage, +) +from django.http import Http404 +from django.http import QueryDict +from django.utils.translation import gettext as _ + +from .settings import api_settings + +from urllib import parse as urlparse + +import warnings + + +def replace_query_param(url, key, val): + """ + Given a URL and a key/val pair, set or replace an item in the query + parameters of the URL, and return the new URL. + """ + (scheme, netloc, path, query, fragment) = urlparse.urlsplit(url) + query_dict = QueryDict(query).copy() + query_dict[key] = val + query = query_dict.urlencode() + return urlparse.urlunsplit((scheme, netloc, path, query, fragment)) + + +def strict_positive_int(integer_string, cutoff=None): + """ + Cast a string to a strictly positive integer. + """ + ret = int(integer_string) + if ret <= 0: + raise ValueError() + if cutoff: + ret = min(ret, cutoff) + return ret + + +class CustomPage(Page): + """Handle different number of items on the first page.""" + + def start_index(self): + """Return the 1-based index of the first item on this page.""" + paginator = self.paginator + # Special case, return zero if no items. + if paginator.count == 0: + return 0 + elif self.number == 1: + return 1 + return ( + (self.number - 2) * paginator.per_page + paginator.first_page + 1) + + def end_index(self): + """Return the 1-based index of the last item on this page.""" + paginator = self.paginator + # Special case for the last page because there can be orphans. + if self.number == paginator.num_pages: + return paginator.count + return (self.number - 1) * paginator.per_page + paginator.first_page + + +class LazyPaginator(Paginator): + """Implement lazy pagination.""" + + def __init__(self, object_list, per_page, **kwargs): + if 'first_page' in kwargs: + self.first_page = kwargs.pop('first_page') + else: + self.first_page = per_page + super(LazyPaginator, self).__init__(object_list, per_page, **kwargs) + + def get_current_per_page(self, number): + return self.first_page if number == 1 else self.per_page + + def validate_number(self, number): + try: + number = int(number) + except ValueError: + raise PageNotAnInteger('That page number is not an integer') + if number < 1: + raise EmptyPage('That page number is less than 1') + return number + + def page(self, number): + number = self.validate_number(number) + current_per_page = self.get_current_per_page(number) + if number == 1: + bottom = 0 + else: + bottom = ((number - 2) * self.per_page + self.first_page) + top = bottom + current_per_page + # Retrieve more objects to check if there is a next page. + objects = list(self.object_list[bottom:top + self.orphans + 1]) + objects_count = len(objects) + if objects_count > (current_per_page + self.orphans): + # If another page is found, increase the total number of pages. + self._num_pages = number + 1 + # In any case, return only objects for this page. + objects = objects[:current_per_page] + elif (number != 1) and (objects_count <= self.orphans): + raise EmptyPage('That page contains no results') + else: + # This is the last page. + self._num_pages = number + return Page(objects, number, self) + + def _get_count(self): + raise NotImplementedError + + count = property(_get_count) + + def _get_num_pages(self): + return self._num_pages + + num_pages = property(_get_num_pages) + + def _get_page_range(self): + raise NotImplementedError + + page_range = property(_get_page_range) + + +class PaginationMixin(object): + # Pagination settings + paginate_by = api_settings.PAGINATE_BY + paginate_by_param = api_settings.PAGINATE_BY_PARAM + max_paginate_by = api_settings.MAX_PAGINATE_BY + page_kwarg = 'page' + paginator_class = Paginator + + def get_paginate_by(self, queryset=None, **kwargs): + """ + Return the size of pages to use with pagination. + + If `PAGINATE_BY_PARAM` is set it will attempt to get the page size + from a named query parameter in the url, eg. ?page_size=100 + + Otherwise defaults to using `self.paginate_by`. + """ + if "x-disable-pagination" in self.request.headers: + return None + + if queryset is not None: + warnings.warn('The `queryset` parameter to `get_paginate_by()` ' + 'is due to be deprecated.', + PendingDeprecationWarning, stacklevel=2) + + if self.paginate_by_param: + try: + return strict_positive_int( + self.request.QUERY_PARAMS[self.paginate_by_param], + cutoff=self.max_paginate_by + ) + except (KeyError, ValueError): + pass + + return self.paginate_by + + def paginate_queryset(self, queryset, page_size=None): + """ + Paginate a queryset if required, either returning a page object, + or `None` if pagination is not configured for this view. + """ + if "x-disable-pagination" in self.request.headers: + return None + + if "x-lazy-pagination" in self.request.headers: + self.paginator_class = LazyPaginator + + deprecated_style = False + if page_size is not None: + warnings.warn('The `page_size` parameter to `paginate_queryset()` ' + 'is due to be deprecated. ' + 'Note that the return style of this method is also ' + 'changed, and will simply return a page object ' + 'when called without a `page_size` argument.', + PendingDeprecationWarning, stacklevel=2) + deprecated_style = True + else: + # Determine the required page size. + # If pagination is not configured, simply return None. + page_size = self.get_paginate_by() + if not page_size: + return None + + if not self.allow_empty: + warnings.warn( + 'The `allow_empty` parameter is due to be deprecated. ' + 'To use `allow_empty=False` style behavior, You should override ' + '`get_queryset()` and explicitly raise a 404 on empty querysets.', + PendingDeprecationWarning, stacklevel=2 + ) + + paginator = self.paginator_class(queryset, page_size, + allow_empty_first_page=self.allow_empty) + + page_kwarg = self.kwargs.get(self.page_kwarg) + page_query_param = self.request.QUERY_PARAMS.get(self.page_kwarg) + page = page_kwarg or page_query_param or 1 + try: + page_number = paginator.validate_number(page) + except InvalidPage: + if page == 'last': + page_number = paginator.num_pages + else: + raise Http404(_("Page is not 'last', nor can it be converted to an int.")) + try: + page = paginator.page(page_number) + except InvalidPage as e: + raise Http404(_('Invalid page (%(page_number)s): %(message)s') % { + 'page_number': page_number, + 'message': str(e) + }) + + if page is None: + return page + + if not "x-lazy-pagination" in self.request.headers: + self.headers["x-pagination-count"] = page.paginator.count + + self.headers["x-paginated"] = "true" + self.headers["x-paginated-by"] = page.paginator.per_page + self.headers["x-pagination-current"] = page.number + + if page.has_next(): + num = page.next_page_number() + url = self.request.build_absolute_uri() + url = replace_query_param(url, "page", num) + self.headers["X-Pagination-Next"] = url + + if page.has_previous(): + num = page.previous_page_number() + url = self.request.build_absolute_uri() + url = replace_query_param(url, "page", num) + self.headers["X-Pagination-Prev"] = url + + return page + + def get_pagination_serializer(self, page): + return self.get_serializer(page.object_list, many=True) diff --git a/taiga/base/api/parsers.py b/taiga/base/api/parsers.py new file mode 100644 index 000000000..eccf9a7c1 --- /dev/null +++ b/taiga/base/api/parsers.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Parsers are used to parse the content of incoming HTTP requests. + +They give us a generic way of being able to handle various media types +on the request, such as form content or json encoded data. +""" +from django.conf import settings +from django.core.files.uploadhandler import StopFutureHandlers +from django.http import QueryDict +from django.http.multipartparser import MultiPartParser as DjangoMultiPartParser +from django.http.multipartparser import MultiPartParserError, parse_header, ChunkIter + +import six + +from taiga.base.exceptions import ParseError +from taiga.base.api import renderers + +import json +import datetime +import decimal + + +class DataAndFiles(object): + def __init__(self, data, files): + self.data = data + self.files = files + + +class BaseParser(object): + """ + All parsers should extend `BaseParser`, specifying a `media_type` + attribute, and overriding the `.parse()` method. + """ + + media_type = None + + def parse(self, stream, media_type=None, parser_context=None): + """ + Given a stream to read from, return the parsed representation. + Should return parsed data, or a `DataAndFiles` object consisting of the + parsed data and files. + """ + raise NotImplementedError(".parse() must be overridden.") + + +class JSONParser(BaseParser): + """ + Parses JSON-serialized data. + """ + + media_type = "application/json" + renderer_class = renderers.UnicodeJSONRenderer + + def parse(self, stream, media_type=None, parser_context=None): + """ + Parses the incoming bytestream as JSON and returns the resulting data. + """ + parser_context = parser_context or {} + encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET) + + try: + data = stream.read().decode(encoding) + return json.loads(data) + except ValueError as exc: + raise ParseError("JSON parse error - %s" % six.text_type(exc)) + + +class FormParser(BaseParser): + """ + Parser for form data. + """ + + media_type = "application/x-www-form-urlencoded" + + def parse(self, stream, media_type=None, parser_context=None): + """ + Parses the incoming bytestream as a URL encoded form, + and returns the resulting QueryDict. + """ + parser_context = parser_context or {} + encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET) + data = QueryDict(stream.read(), encoding=encoding) + return data + + +class MultiPartParser(BaseParser): + """ + Parser for multipart form data, which may include file data. + """ + + media_type = "multipart/form-data" + + def parse(self, stream, media_type=None, parser_context=None): + """ + Parses the incoming bytestream as a multipart encoded form, + and returns a DataAndFiles object. + + `.data` will be a `QueryDict` containing all the form parameters. + `.files` will be a `QueryDict` containing all the form files. + """ + parser_context = parser_context or {} + request = parser_context["request"] + encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET) + meta = request.META.copy() + meta["CONTENT_TYPE"] = media_type + upload_handlers = request.upload_handlers + + try: + parser = DjangoMultiPartParser(meta, stream, upload_handlers, encoding) + data, files = parser.parse() + return DataAndFiles(data, files) + except MultiPartParserError as exc: + raise ParseError("Multipart form parse error - %s" % str(exc)) + + +class FileUploadParser(BaseParser): + """ + Parser for file upload data. + """ + media_type = "*/*" + + def parse(self, stream, media_type=None, parser_context=None): + """ + Treats the incoming bytestream as a raw file upload and returns + a `DateAndFiles` object. + + `.data` will be None (we expect request body to be a file content). + `.files` will be a `QueryDict` containing one "file" element. + """ + + parser_context = parser_context or {} + request = parser_context["request"] + encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET) + meta = request.META + upload_handlers = request.upload_handlers + filename = self.get_filename(stream, media_type, parser_context) + + # Note that this code is extracted from Django's handling of + # file uploads in MultiPartParser. + content_type = meta.get("HTTP_CONTENT_TYPE", + meta.get("CONTENT_TYPE", "")) + try: + content_length = int(meta.get("HTTP_CONTENT_LENGTH", + meta.get("CONTENT_LENGTH", 0))) + except (ValueError, TypeError): + content_length = None + + # See if the handler will want to take care of the parsing. + for handler in upload_handlers: + result = handler.handle_raw_input(None, + meta, + content_length, + None, + encoding) + if result is not None: + return DataAndFiles(None, {"file": result[1]}) + + # This is the standard case. + possible_sizes = [x.chunk_size for x in upload_handlers if x.chunk_size] + chunk_size = min([2 ** 31 - 4] + possible_sizes) + chunks = ChunkIter(stream, chunk_size) + counters = [0] * len(upload_handlers) + + for handler in upload_handlers: + try: + handler.new_file(None, filename, content_type, + content_length, encoding) + except StopFutureHandlers: + break + + for chunk in chunks: + for i, handler in enumerate(upload_handlers): + chunk_length = len(chunk) + chunk = handler.receive_data_chunk(chunk, counters[i]) + counters[i] += chunk_length + if chunk is None: + break + + for i, handler in enumerate(upload_handlers): + file_obj = handler.file_complete(counters[i]) + if file_obj: + return DataAndFiles(None, {"file": file_obj}) + raise ParseError("FileUpload parse error - " + "none of upload handlers can handle the stream") + + def get_filename(self, stream, media_type, parser_context): + """ + Detects the uploaded file name. First searches a "filename" url kwarg. + Then tries to parse Content-Disposition header. + """ + try: + return parser_context["kwargs"]["filename"] + except KeyError: + pass + + try: + meta = parser_context["request"].META + disposition = parse_header(meta["HTTP_CONTENT_DISPOSITION"]) + return disposition[1]["filename"] + except (AttributeError, KeyError): + pass diff --git a/taiga/base/api/permissions.py b/taiga/base/api/permissions.py new file mode 100644 index 000000000..5bb7d0b48 --- /dev/null +++ b/taiga/base/api/permissions.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import abc +import inspect + +from functools import reduce + +from taiga.permissions.services import user_has_perm, is_project_admin + +from django.utils.translation import gettext as _ + + +###################################################################### +# Base permissiones definition +###################################################################### + +class ResourcePermission(object): + """ + Base class for define resource permissions. + """ + + enough_perms = None + global_perms = None + retrieve_perms = None + create_perms = None + update_perms = None + destroy_perms = None + list_perms = None + + def __init__(self, request, view): + self.request = request + self.view = view + + def check_permissions(self, action:str, obj:object=None): + permset = getattr(self, "{}_perms".format(action)) + + if isinstance(permset, (list, tuple)): + permset = reduce(lambda acc, v: acc & v, permset) + elif permset is None: + # Use empty operator that always return true with + # empty components. + permset = And() + elif isinstance(permset, PermissionComponent): + # Do nothing + pass + elif inspect.isclass(permset) and issubclass(permset, PermissionComponent): + permset = permset() + else: + raise RuntimeError(_("Invalid permission definition.")) + + if self.global_perms: + permset = (self.global_perms & permset) + + if self.enough_perms: + permset = (self.enough_perms | permset) + + return permset.check_permissions(request=self.request, + view=self.view, + obj=obj) + + +class PermissionComponent(object, metaclass=abc.ABCMeta): + @abc.abstractmethod + def check_permissions(self, request, view, obj=None): + pass + + def __invert__(self): + return Not(self) + + def __and__(self, component): + return And(self, component) + + def __or__(self, component): + return Or(self, component) + + +class PermissionOperator(PermissionComponent): + """ + Base class for all logical operators for compose + components. + """ + + def __init__(self, *components): + self.components = tuple(components) + + +class Not(PermissionOperator): + """ + Negation operator as permission composable component. + """ + + # Overwrites the default constructor for fix + # to one parameter instead of variable list of them. + def __init__(self, component): + super().__init__(component) + + def check_permissions(self, *args, **kwargs): + component = self.components[0] + return (not component.check_permissions(*args, **kwargs)) + + +class Or(PermissionOperator): + """ + Or logical operator as permission component. + """ + + def check_permissions(self, *args, **kwargs): + valid = False + + for component in self.components: + if component.check_permissions(*args, **kwargs): + valid = True + break + + return valid + + +class And(PermissionOperator): + """ + And logical operator as permission component. + """ + + def check_permissions(self, *args, **kwargs): + valid = True + + for component in self.components: + if not component.check_permissions(*args, **kwargs): + valid = False + break + + return valid + + +###################################################################### +# Generic components. +###################################################################### + +class AllowAny(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return True + + +class DenyAll(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return False + + +class IsAuthenticated(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return request.user and request.user.is_authenticated + + +class IsSuperUser(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return request.user and request.user.is_authenticated and request.user.is_superuser + + +class HasProjectPerm(PermissionComponent): + def __init__(self, perm, *components): + self.project_perm = perm + super().__init__(*components) + + def check_permissions(self, request, view, obj=None): + return user_has_perm(request.user, self.project_perm, obj) + + +class IsProjectAdmin(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return is_project_admin(request.user, obj) + + +class IsObjectOwner(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if obj.owner is None: + return False + + return obj.owner == request.user + + +###################################################################### +# Generic permissions. +###################################################################### + +class AllowAnyPermission(ResourcePermission): + enough_perms = AllowAny() + + +class IsAuthenticatedPermission(ResourcePermission): + enough_perms = IsAuthenticated() + + +class TaigaResourcePermission(ResourcePermission): + enough_perms = IsSuperUser() diff --git a/taiga/base/api/relations.py b/taiga/base/api/relations.py new file mode 100644 index 000000000..14cc3841e --- /dev/null +++ b/taiga/base/api/relations.py @@ -0,0 +1,643 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Serializer fields that deal with relationships. + +These fields allow you to specify the style that should be used to represent +model relationships, including hyperlinks, primary keys, or slugs. +""" +from django.core.exceptions import ObjectDoesNotExist +from django.urls import resolve, get_script_prefix, NoReverseMatch +from django import forms +from django.db.models.fields import BLANK_CHOICE_DASH +from django.forms import widgets +from django.forms.models import ModelChoiceIterator +from django.utils.encoding import smart_str +from django.utils.translation import gettext_lazy as _ + +from .fields import Field, WritableField, get_component, is_simple_callable +from .reverse import reverse +from taiga.base.exceptions import ValidationError + +import warnings +from urllib import parse as urlparse + + + + +##### Relational fields ##### + + +# Not actually Writable, but subclasses may need to be. +class RelatedField(WritableField): + """ + Base class for related model fields. + + This represents a relationship using the unicode representation of the target. + """ + widget = widgets.Select + many_widget = widgets.SelectMultiple + form_field_class = forms.ChoiceField + many_form_field_class = forms.MultipleChoiceField + null_values = (None, "", "None") + + cache_choices = False + empty_label = None + read_only = True + many = False + + def __init__(self, *args, **kwargs): + + # "null" is to be deprecated in favor of "required" + if "null" in kwargs: + warnings.warn("The `null` keyword argument is deprecated. " + "Use the `required` keyword argument instead.", + DeprecationWarning, stacklevel=2) + kwargs["required"] = not kwargs.pop("null") + + queryset = kwargs.pop("queryset", None) + self.many = kwargs.pop("many", self.many) + if self.many: + self.widget = self.many_widget + self.form_field_class = self.many_form_field_class + + kwargs["read_only"] = kwargs.pop("read_only", self.read_only) + super(RelatedField, self).__init__(*args, **kwargs) + + if not self.required: + self.empty_label = BLANK_CHOICE_DASH[0][1] + + self.queryset = queryset + + def initialize(self, parent, field_name): + super(RelatedField, self).initialize(parent, field_name) + if self.queryset is None and not self.read_only: + manager = getattr(self.parent.opts.model, self.source or field_name) + if hasattr(manager, "related"): # Forward + self.queryset = manager.related.model._default_manager.all() + else: # Reverse + self.queryset = manager.field.remote_field.model._default_manager.all() + + ### We need this stuff to make form choices work... + + def prepare_value(self, obj): + return self.to_native(obj) + + def label_from_instance(self, obj): + """ + Return a readable representation for use with eg. select widgets. + """ + desc = smart_str(obj) + ident = smart_str(self.to_native(obj)) + if desc == ident: + return desc + return "%s - %s" % (desc, ident) + + def _get_queryset(self): + return self._queryset + + def _set_queryset(self, queryset): + self._queryset = queryset + self.widget.choices = self.choices + + queryset = property(_get_queryset, _set_queryset) + + def _get_choices(self): + # If self._choices is set, then somebody must have manually set + # the property self.choices. In this case, just return self._choices. + if hasattr(self, "_choices"): + return self._choices + + # Otherwise, execute the QuerySet in self.queryset to determine the + # choices dynamically. Return a fresh ModelChoiceIterator that has not been + # consumed. Note that we"re instantiating a new ModelChoiceIterator *each* + # time _get_choices() is called (and, thus, each time self.choices is + # accessed) so that we can ensure the QuerySet has not been consumed. This + # construct might look complicated but it allows for lazy evaluation of + # the queryset. + return ModelChoiceIterator(self) + + def _set_choices(self, value): + # Setting choices also sets the choices on the widget. + # choices can be any iterable, but we call list() on it because + # it will be consumed more than once. + self._choices = self.widget.choices = list(value) + + choices = property(_get_choices, _set_choices) + + ### Default value handling + + def get_default_value(self): + default = super(RelatedField, self).get_default_value() + if self.many and default is None: + return [] + return default + + ### Regular serializer stuff... + + def field_to_native(self, obj, field_name): + try: + if self.source == "*": + return self.to_native(obj) + + source = self.source or field_name + value = obj + + for component in source.split("."): + if value is None: + break + value = get_component(value, component) + except ObjectDoesNotExist: + return None + + if value is None: + return None + + if self.many: + if is_simple_callable(getattr(value, "all", None)): + return [self.to_native(item) for item in value.all()] + else: + # Also support non-queryset iterables. + # This allows us to also support plain lists of related items. + return [self.to_native(item) for item in value] + return self.to_native(value) + + def field_from_native(self, data, files, field_name, into): + if self.read_only: + return + + try: + if self.many: + try: + # Form data + value = data.getlist(field_name) + if value == [""] or value == []: + raise KeyError + except AttributeError: + # Non-form data + value = data[field_name] + else: + value = data[field_name] + except KeyError: + if self.partial: + return + value = self.get_default_value() + + if value in self.null_values: + if self.required: + raise ValidationError(self.error_messages["required"]) + into[(self.source or field_name)] = None + elif self.many: + into[(self.source or field_name)] = [self.from_native(item) for item in value] + else: + into[(self.source or field_name)] = self.from_native(value) + + +### PrimaryKey relationships + +class PrimaryKeyRelatedField(RelatedField): + """ + Represents a relationship as a pk value. + """ + read_only = False + + default_error_messages = { + "does_not_exist": _("Invalid pk '%s' - object does not exist."), + "incorrect_type": _("Incorrect type. Expected pk value, received %s."), + } + + # TODO: Remove these field hacks... + def prepare_value(self, obj): + return self.to_native(obj.pk) + + def label_from_instance(self, obj): + """ + Return a readable representation for use with eg. select widgets. + """ + desc = smart_str(obj) + ident = smart_str(self.to_native(obj.pk)) + if desc == ident: + return desc + return "%s - %s" % (desc, ident) + + # TODO: Possibly change this to just take `obj`, through prob less performant + def to_native(self, pk): + return pk + + def from_native(self, data): + if self.queryset is None: + raise Exception("Writable related fields must include a `queryset` argument") + + try: + return self.queryset.get(pk=data) + except ObjectDoesNotExist: + msg = self.error_messages["does_not_exist"] % smart_str(data) + raise ValidationError(msg) + except (TypeError, ValueError): + received = type(data).__name__ + msg = self.error_messages["incorrect_type"] % received + raise ValidationError(msg) + + def field_to_native(self, obj, field_name): + if self.many: + # To-many relationship + + queryset = None + if not self.source: + # Prefer obj.serializable_value for performance reasons + try: + queryset = obj.serializable_value(field_name) + except AttributeError: + pass + if queryset is None: + # RelatedManager (reverse relationship) + source = self.source or field_name + queryset = obj + for component in source.split("."): + if queryset is None: + return [] + queryset = get_component(queryset, component) + + # Forward relationship + if is_simple_callable(getattr(queryset, "all", None)): + return [self.to_native(item.pk) for item in queryset.all()] + else: + # Also support non-queryset iterables. + # This allows us to also support plain lists of related items. + return [self.to_native(item.pk) for item in queryset] + + # To-one relationship + try: + # Prefer obj.serializable_value for performance reasons + pk = obj.serializable_value(self.source or field_name) + except AttributeError: + # RelatedObject (reverse relationship) + try: + pk = getattr(obj, self.source or field_name).pk + except (ObjectDoesNotExist, AttributeError): + return None + + # Forward relationship + return self.to_native(pk) + + +### Slug relationships + + +class SlugRelatedField(RelatedField): + """ + Represents a relationship using a unique field on the target. + """ + read_only = False + + default_error_messages = { + "does_not_exist": _("Object with %s=%s does not exist."), + "invalid": _("Invalid value."), + } + + def __init__(self, *args, **kwargs): + self.slug_field = kwargs.pop("slug_field", None) + assert self.slug_field, "slug_field is required" + super(SlugRelatedField, self).__init__(*args, **kwargs) + + def to_native(self, obj): + return getattr(obj, self.slug_field) + + def from_native(self, data): + if self.queryset is None: + raise Exception("Writable related fields must include a `queryset` argument") + + try: + return self.queryset.get(**{self.slug_field: data}) + except ObjectDoesNotExist: + raise ValidationError(self.error_messages["does_not_exist"] % + (self.slug_field, smart_str(data))) + except (TypeError, ValueError): + msg = self.error_messages["invalid"] + raise ValidationError(msg) + + +### Hyperlinked relationships + +class HyperlinkedRelatedField(RelatedField): + """ + Represents a relationship using hyperlinking. + """ + read_only = False + lookup_field = "pk" + + default_error_messages = { + "no_match": _("Invalid hyperlink - No URL match"), + "incorrect_match": _("Invalid hyperlink - Incorrect URL match"), + "configuration_error": _("Invalid hyperlink due to configuration error"), + "does_not_exist": _("Invalid hyperlink - object does not exist."), + "incorrect_type": _("Incorrect type. Expected url string, received %s."), + } + + # These are all pending deprecation + pk_url_kwarg = "pk" + slug_field = "slug" + slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden + + def __init__(self, *args, **kwargs): + try: + self.view_name = kwargs.pop("view_name") + except KeyError: + raise ValueError("Hyperlinked field requires \"view_name\" kwarg") + + self.lookup_field = kwargs.pop("lookup_field", self.lookup_field) + self.format = kwargs.pop("format", None) + + # These are pending deprecation + if "pk_url_kwarg" in kwargs: + msg = "pk_url_kwarg is pending deprecation. Use lookup_field instead." + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + if "slug_url_kwarg" in kwargs: + msg = "slug_url_kwarg is pending deprecation. Use lookup_field instead." + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + if "slug_field" in kwargs: + msg = "slug_field is pending deprecation. Use lookup_field instead." + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + + self.pk_url_kwarg = kwargs.pop("pk_url_kwarg", self.pk_url_kwarg) + self.slug_field = kwargs.pop("slug_field", self.slug_field) + default_slug_kwarg = self.slug_url_kwarg or self.slug_field + self.slug_url_kwarg = kwargs.pop("slug_url_kwarg", default_slug_kwarg) + + super(HyperlinkedRelatedField, self).__init__(*args, **kwargs) + + def get_url(self, obj, view_name, request, format): + """ + Given an object, return the URL that hyperlinks to the object. + + May raise a `NoReverseMatch` if the `view_name` and `lookup_field` + attributes are not configured to correctly match the URL conf. + """ + lookup_field = getattr(obj, self.lookup_field) + kwargs = {self.lookup_field: lookup_field} + try: + return reverse(view_name, kwargs=kwargs, request=request, format=format) + except NoReverseMatch: + pass + + if self.pk_url_kwarg != "pk": + # Only try pk if it has been explicitly set. + # Otherwise, the default `lookup_field = "pk"` has us covered. + pk = obj.pk + kwargs = {self.pk_url_kwarg: pk} + try: + return reverse(view_name, kwargs=kwargs, request=request, format=format) + except NoReverseMatch: + pass + + slug = getattr(obj, self.slug_field, None) + if slug is not None: + # Only try slug if it corresponds to an attribute on the object. + kwargs = {self.slug_url_kwarg: slug} + try: + ret = reverse(view_name, kwargs=kwargs, request=request, format=format) + if self.slug_field == "slug" and self.slug_url_kwarg == "slug": + # If the lookup succeeds using the default slug params, + # then `slug_field` is being used implicitly, and we + # we need to warn about the pending deprecation. + msg = "Implicit slug field hyperlinked fields are pending deprecation." \ + "You should set `lookup_field=slug` on the HyperlinkedRelatedField." + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + return ret + except NoReverseMatch: + pass + + raise NoReverseMatch() + + def get_object(self, queryset, view_name, view_args, view_kwargs): + """ + Return the object corresponding to a matched URL. + + Takes the matched URL conf arguments, and the queryset, and should + return an object instance, or raise an `ObjectDoesNotExist` exception. + """ + lookup = view_kwargs.get(self.lookup_field, None) + pk = view_kwargs.get(self.pk_url_kwarg, None) + slug = view_kwargs.get(self.slug_url_kwarg, None) + + if lookup is not None: + filter_kwargs = {self.lookup_field: lookup} + elif pk is not None: + filter_kwargs = {"pk": pk} + elif slug is not None: + filter_kwargs = {self.slug_field: slug} + else: + raise ObjectDoesNotExist() + + return queryset.get(**filter_kwargs) + + def to_native(self, obj): + view_name = self.view_name + request = self.context.get("request", None) + format = self.format or self.context.get("format", None) + + if request is None: + msg = ( + "Using `HyperlinkedRelatedField` without including the request " + "in the serializer context is deprecated. " + "Add `context={'request': request}` when instantiating " + "the serializer." + ) + warnings.warn(msg, DeprecationWarning, stacklevel=4) + + # If the object has not yet been saved then we cannot hyperlink to it. + if getattr(obj, "pk", None) is None: + return + + # Return the hyperlink, or error if incorrectly configured. + try: + return self.get_url(obj, view_name, request, format) + except NoReverseMatch: + msg = ( + "Could not resolve URL for hyperlinked relationship using " + "view name '%s'. You may have failed to include the related " + "model in your API, or incorrectly configured the " + "`lookup_field` attribute on this field." + ) + raise Exception(msg % view_name) + + def from_native(self, value): + # Convert URL -> model instance pk + # TODO: Use values_list + queryset = self.queryset + if queryset is None: + raise Exception("Writable related fields must include a `queryset` argument") + + try: + http_prefix = value.startswith(("http:", "https:")) + except AttributeError: + msg = self.error_messages["incorrect_type"] + raise ValidationError(msg % type(value).__name__) + + if http_prefix: + # If needed convert absolute URLs to relative path + value = urlparse.urlparse(value).path + prefix = get_script_prefix() + if value.startswith(prefix): + value = "/" + value[len(prefix):] + + try: + match = resolve(value) + except Exception: + raise ValidationError(self.error_messages["no_match"]) + + if match.view_name != self.view_name: + raise ValidationError(self.error_messages["incorrect_match"]) + + try: + return self.get_object(queryset, match.view_name, + match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + raise ValidationError(self.error_messages["does_not_exist"]) + + +class HyperlinkedIdentityField(Field): + """ + Represents the instance, or a property on the instance, using hyperlinking. + """ + lookup_field = "pk" + read_only = True + + # These are all pending deprecation + pk_url_kwarg = "pk" + slug_field = "slug" + slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden + + def __init__(self, *args, **kwargs): + try: + self.view_name = kwargs.pop("view_name") + except KeyError: + msg = "HyperlinkedIdentityField requires \"view_name\" argument" + raise ValueError(msg) + + self.format = kwargs.pop("format", None) + lookup_field = kwargs.pop("lookup_field", None) + self.lookup_field = lookup_field or self.lookup_field + + # These are pending deprecation + if "pk_url_kwarg" in kwargs: + msg = "pk_url_kwarg is pending deprecation. Use lookup_field instead." + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + if "slug_url_kwarg" in kwargs: + msg = "slug_url_kwarg is pending deprecation. Use lookup_field instead." + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + if "slug_field" in kwargs: + msg = "slug_field is pending deprecation. Use lookup_field instead." + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + + self.slug_field = kwargs.pop("slug_field", self.slug_field) + default_slug_kwarg = self.slug_url_kwarg or self.slug_field + self.pk_url_kwarg = kwargs.pop("pk_url_kwarg", self.pk_url_kwarg) + self.slug_url_kwarg = kwargs.pop("slug_url_kwarg", default_slug_kwarg) + + super(HyperlinkedIdentityField, self).__init__(*args, **kwargs) + + def field_to_native(self, obj, field_name): + request = self.context.get("request", None) + format = self.context.get("format", None) + view_name = self.view_name + + if request is None: + warnings.warn("Using `HyperlinkedIdentityField` without including the " + "request in the serializer context is deprecated. " + "Add `context={'request': request}` when instantiating the serializer.", + DeprecationWarning, stacklevel=4) + + # By default use whatever format is given for the current context + # unless the target is a different type to the source. + # + # Eg. Consider a HyperlinkedIdentityField pointing from a json + # representation to an html property of that representation... + # + # "/snippets/1/" should link to "/snippets/1/highlight/" + # ...but... + # "/snippets/1/.json" should link to "/snippets/1/highlight/.html" + if format and self.format and self.format != format: + format = self.format + + # Return the hyperlink, or error if incorrectly configured. + try: + return self.get_url(obj, view_name, request, format) + except NoReverseMatch: + msg = ( + "Could not resolve URL for hyperlinked relationship using " + "view name '%s'. You may have failed to include the related " + "model in your API, or incorrectly configured the " + "`lookup_field` attribute on this field." + ) + raise Exception(msg % view_name) + + def get_url(self, obj, view_name, request, format): + """ + Given an object, return the URL that hyperlinks to the object. + + May raise a `NoReverseMatch` if the `view_name` and `lookup_field` + attributes are not configured to correctly match the URL conf. + """ + lookup_field = getattr(obj, self.lookup_field, None) + kwargs = {self.lookup_field: lookup_field} + + # Handle unsaved object case + if lookup_field is None: + return None + + try: + return reverse(view_name, kwargs=kwargs, request=request, format=format) + except NoReverseMatch: + pass + + if self.pk_url_kwarg != "pk": + # Only try pk lookup if it has been explicitly set. + # Otherwise, the default `lookup_field = "pk"` has us covered. + kwargs = {self.pk_url_kwarg: obj.pk} + try: + return reverse(view_name, kwargs=kwargs, request=request, format=format) + except NoReverseMatch: + pass + + slug = getattr(obj, self.slug_field, None) + if slug: + # Only use slug lookup if a slug field exists on the model + kwargs = {self.slug_url_kwarg: slug} + try: + return reverse(view_name, kwargs=kwargs, request=request, format=format) + except NoReverseMatch: + pass + + raise NoReverseMatch() diff --git a/taiga/base/api/renderers.py b/taiga/base/api/renderers.py new file mode 100644 index 000000000..98e66aba1 --- /dev/null +++ b/taiga/base/api/renderers.py @@ -0,0 +1,300 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Renderers are used to serialize a response into specific media types. + +They give us a generic way of being able to handle various media types +on the response, such as JSON encoded data or HTML output. + +REST framework also provides an HTML renderer the renders the browsable API. +""" + +from django.core.exceptions import ImproperlyConfigured +from django.http.multipartparser import parse_header +from django.template import RequestContext, loader, Template +from django.test.client import encode_multipart +import six + +from .utils import encoders + +import json + + +class BaseRenderer(object): + """ + All renderers should extend this class, setting the `media_type` + and `format` attributes, and override the `.render()` method. + """ + + media_type = None + format = None + charset = "utf-8" + render_style = "text" + + def render(self, data, accepted_media_type=None, renderer_context=None): + raise NotImplemented("Renderer class requires .render() to be implemented") + + +class JSONRenderer(BaseRenderer): + """ + Renderer which serializes to JSON. + Applies JSON's backslash-u character escaping for non-ascii characters. + """ + + media_type = "application/json" + format = "json" + encoder_class = encoders.JSONEncoder + ensure_ascii = True + charset = None + # JSON is a binary encoding, that can be encoded as utf-8, utf-16 or utf-32. + # See: http://www.ietf.org/rfc/rfc4627.txt + # Also: http://lucumr.pocoo.org/2013/7/19/application-mimetypes-and-encodings/ + + def _get_indent(self, accepted_media_type, renderer_context): + # If "indent" is provided in the context, then pretty print the result. + # E.g. If we"re being called by the BrowsableAPIRenderer. + renderer_context = renderer_context or {} + indent = renderer_context.get("indent", None) + + if accepted_media_type: + # If the media type looks like "application/json; indent=4", + # then pretty print the result. + base_media_type, params = parse_header(accepted_media_type.encode("ascii")) + indent = params.get("indent", indent) + try: + indent = max(min(int(indent), 8), 0) + except (ValueError, TypeError): + indent = None + + return indent + + def render(self, data, accepted_media_type=None, renderer_context=None): + """ + Render `data` into JSON. + """ + if data is None: + return bytes() + + indent = self._get_indent(accepted_media_type, renderer_context) + + ret = json.dumps(data, cls=self.encoder_class, + indent=indent, ensure_ascii=self.ensure_ascii) + + # On python 2.x json.dumps() returns bytestrings if ensure_ascii=True, + # but if ensure_ascii=False, the return type is underspecified, + # and may (or may not) be unicode. + # On python 3.x json.dumps() returns unicode strings. + if isinstance(ret, six.text_type): + return bytes(ret.encode("utf-8")) + return ret + + def render_to_file(self, data, outputfile, accepted_media_type=None, renderer_context=None): + """ + Render `data` into a file with JSON format. + """ + if data is None: + return bytes() + + indent = self._get_indent(accepted_media_type, renderer_context) + + ret = json.dump(data, outputfile, cls=self.encoder_class, + indent=indent, ensure_ascii=self.ensure_ascii) + + +class UnicodeJSONRenderer(JSONRenderer): + ensure_ascii = False + """ + Renderer which serializes to JSON. + Does *not* apply JSON's character escaping for non-ascii characters. + """ + + +class JSONPRenderer(JSONRenderer): + """ + Renderer which serializes to json, + wrapping the json output in a callback function. + """ + + media_type = "application/javascript" + format = "jsonp" + callback_parameter = "callback" + default_callback = "callback" + charset = "utf-8" + + def get_callback(self, renderer_context): + """ + Determine the name of the callback to wrap around the json output. + """ + request = renderer_context.get("request", None) + params = request and request.QUERY_PARAMS or {} + return params.get(self.callback_parameter, self.default_callback) + + def render(self, data, accepted_media_type=None, renderer_context=None): + """ + Renders into jsonp, wrapping the json output in a callback function. + + Clients may set the callback function name using a query parameter + on the URL, for example: ?callback=exampleCallbackName + """ + renderer_context = renderer_context or {} + callback = self.get_callback(renderer_context) + json = super(JSONPRenderer, self).render(data, accepted_media_type, + renderer_context) + return callback.encode(self.charset) + b"(" + json + b");" + + +class TemplateHTMLRenderer(BaseRenderer): + """ + An HTML renderer for use with templates. + + The data supplied to the Response object should be a dictionary that will + be used as context for the template. + + The template name is determined by (in order of preference): + + 1. An explicit `.template_name` attribute set on the response. + 2. An explicit `.template_name` attribute set on this class. + 3. The return result of calling `view.get_template_names()`. + + For example: + data = {"users": User.objects.all()} + return Response(data, template_name="users.html") + + For pre-rendered HTML, see StaticHTMLRenderer. + """ + + media_type = "text/html" + format = "html" + template_name = None + exception_template_names = [ + "%(status_code)s.html", + "api_exception.html" + ] + charset = "utf-8" + + def render(self, data, accepted_media_type=None, renderer_context=None): + """ + Renders data to HTML, using Django's standard template rendering. + + The template name is determined by (in order of preference): + + 1. An explicit .template_name set on the response. + 2. An explicit .template_name set on this class. + 3. The return result of calling view.get_template_names(). + """ + renderer_context = renderer_context or {} + view = renderer_context["view"] + request = renderer_context["request"] + response = renderer_context["response"] + + if response.exception: + template = self.get_exception_template(response) + else: + template_names = self.get_template_names(response, view) + template = self.resolve_template(template_names) + + context = self.resolve_context(data, request, response) + return template.render(context) + + def resolve_template(self, template_names): + return loader.select_template(template_names) + + def resolve_context(self, data, request, response): + if response.exception: + data["status_code"] = response.status_code + return RequestContext(request, data) + + def get_template_names(self, response, view): + if response.template_name: + return [response.template_name] + elif self.template_name: + return [self.template_name] + elif hasattr(view, "get_template_names"): + return view.get_template_names() + elif hasattr(view, "template_name"): + return [view.template_name] + raise ImproperlyConfigured("Returned a template response with no `template_name` attribute set on either the view or response") + + def get_exception_template(self, response): + template_names = [name % {"status_code": response.status_code} + for name in self.exception_template_names] + + try: + # Try to find an appropriate error template + return self.resolve_template(template_names) + except Exception: + # Fall back to using eg "404 Not Found" + return Template("%d %s" % (response.status_code, + response.status_text.title())) + + +# Note, subclass TemplateHTMLRenderer simply for the exception behavior +class StaticHTMLRenderer(TemplateHTMLRenderer): + """ + An HTML renderer class that simply returns pre-rendered HTML. + + The data supplied to the Response object should be a string representing + the pre-rendered HTML content. + + For example: + data = "example" + return Response(data) + + For template rendered HTML, see TemplateHTMLRenderer. + """ + media_type = "text/html" + format = "html" + charset = "utf-8" + + def render(self, data, accepted_media_type=None, renderer_context=None): + renderer_context = renderer_context or {} + response = renderer_context["response"] + + if response and response.exception: + request = renderer_context["request"] + template = self.get_exception_template(response) + context = self.resolve_context(data, request, response) + return template.render(context) + + return data + + +class MultiPartRenderer(BaseRenderer): + media_type = "multipart/form-data; boundary=BoUnDaRyStRiNg" + format = "multipart" + charset = "utf-8" + BOUNDARY = "BoUnDaRyStRiNg" + + def render(self, data, accepted_media_type=None, renderer_context=None): + return encode_multipart(self.BOUNDARY, data) diff --git a/taiga/base/api/request.py b/taiga/base/api/request.py new file mode 100644 index 000000000..712ab5754 --- /dev/null +++ b/taiga/base/api/request.py @@ -0,0 +1,454 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +The Request class is used as a wrapper around the standard request object. + +The wrapped request then offers a richer API, in particular : + + - content automatically parsed according to `Content-Type` header, + and available as `request.DATA` + - full support of PUT method, including support for file uploads + - form overloading of HTTP method, content type and content +""" +from django.conf import settings +from django.http import QueryDict +from django.http.multipartparser import parse_header +from django.utils.datastructures import MultiValueDict +import six + +from taiga.base import exceptions + +from . import HTTP_HEADER_ENCODING +from .settings import api_settings + + +def is_form_media_type(media_type): + """ + Return True if the media type is a valid form media type. + """ + base_media_type, params = parse_header(media_type.encode(HTTP_HEADER_ENCODING)) + return (base_media_type == "application/x-www-form-urlencoded" or + base_media_type == "multipart/form-data") + + +class override_method(object): + """ + A context manager that temporarily overrides the method on a request, + additionally setting the `view.request` attribute. + + Usage: + + with override_method(view, request, "POST") as request: + ... # Do stuff with `view` and `request` + """ + def __init__(self, view, request, method): + self.view = view + self.request = request + self.method = method + + def __enter__(self): + self.view.request = clone_request(self.request, self.method) + return self.view.request + + def __exit__(self, *args, **kwarg): + self.view.request = self.request + + +class Empty(object): + """ + Placeholder for unset attributes. + Cannot use `None`, as that may be a valid value. + """ + pass + + +def _hasattr(obj, name): + return not getattr(obj, name) is Empty + + +def clone_request(request, method): + """ + Internal helper method to clone a request, replacing with a different + HTTP method. Used for checking permissions against other methods. + """ + ret = Request(request=request._request, + parsers=request.parsers, + authenticators=request.authenticators, + negotiator=request.negotiator, + parser_context=request.parser_context) + ret._data = request._data + ret._files = request._files + ret._content_type = request._content_type + ret._stream = request._stream + ret._method = method + if hasattr(request, "_user"): + ret._user = request._user + if hasattr(request, "_auth"): + ret._auth = request._auth + if hasattr(request, "_authenticator"): + ret._authenticator = request._authenticator + return ret + + +class ForcedAuthentication(object): + """ + This authentication class is used if the test client or request factory + forcibly authenticated the request. + """ + + def __init__(self, force_user, force_token): + self.force_user = force_user + self.force_token = force_token + + def authenticate(self, request): + return (self.force_user, self.force_token) + + +class Request(object): + """ + Wrapper allowing to enhance a standard `HttpRequest` instance. + + Kwargs: + - request(HttpRequest). The original request instance. + - parsers_classes(list/tuple). The parsers to use for parsing the + request content. + - authentication_classes(list/tuple). The authentications used to try + authenticating the request's user. + """ + + _METHOD_PARAM = api_settings.FORM_METHOD_OVERRIDE + _CONTENT_PARAM = api_settings.FORM_CONTENT_OVERRIDE + _CONTENTTYPE_PARAM = api_settings.FORM_CONTENTTYPE_OVERRIDE + + def __init__(self, request, parsers=None, authenticators=None, + negotiator=None, parser_context=None): + self._request = request + self.parsers = parsers or () + self.authenticators = authenticators or () + self.negotiator = negotiator or self._default_negotiator() + self.parser_context = parser_context + self._data = Empty + self._files = Empty + self._method = Empty + self._content_type = Empty + self._stream = Empty + + if self.parser_context is None: + self.parser_context = {} + self.parser_context["request"] = self + self.parser_context["encoding"] = request.encoding or settings.DEFAULT_CHARSET + + force_user = getattr(request, "_force_auth_user", None) + force_token = getattr(request, "_force_auth_token", None) + if (force_user is not None or force_token is not None): + forced_auth = ForcedAuthentication(force_user, force_token) + self.authenticators = (forced_auth,) + + def _default_negotiator(self): + return api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS() + + @property + def method(self): + """ + Returns the HTTP method. + + This allows the `method` to be overridden by using a hidden `form` + field on a form POST request. + """ + if not _hasattr(self, "_method"): + self._load_method_and_content_type() + return self._method + + @property + def content_type(self): + """ + Returns the content type header. + + This should be used instead of `request.META.get("HTTP_CONTENT_TYPE")`, + as it allows the content type to be overridden by using a hidden form + field on a form POST request. + """ + if not _hasattr(self, "_content_type"): + self._load_method_and_content_type() + return self._content_type + + @property + def stream(self): + """ + Returns an object that may be used to stream the request content. + """ + if not _hasattr(self, "_stream"): + self._load_stream() + return self._stream + + @property + def QUERY_PARAMS(self): + """ + More semantically correct name for request.GET. + """ + return self._request.GET + + @property + def DATA(self): + """ + Parses the request body and returns the data. + + Similar to usual behaviour of `request.POST`, except that it handles + arbitrary parsers, and also works on methods other than POST (eg PUT). + """ + if not _hasattr(self, "_data"): + self._load_data_and_files() + return self._data + + @property + def FILES(self): + """ + Parses the request body and returns any files uploaded in the request. + + Similar to usual behaviour of `request.FILES`, except that it handles + arbitrary parsers, and also works on methods other than POST (eg PUT). + """ + if not _hasattr(self, "_files"): + self._load_data_and_files() + return self._files + + @property + def user(self): + """ + Returns the user associated with the current request, as authenticated + by the authentication classes provided to the request. + """ + if not hasattr(self, "_user"): + self._authenticate() + return self._user + + @user.setter + def user(self, value): + """ + Sets the user on the current request. This is necessary to maintain + compatibility with django.contrib.auth where the user property is + set in the login and logout functions. + """ + self._user = value + + @property + def auth(self): + """ + Returns any non-user authentication information associated with the + request, such as an authentication token. + """ + if not hasattr(self, "_auth"): + self._authenticate() + return self._auth + + @auth.setter + def auth(self, value): + """ + Sets any non-user authentication information associated with the + request, such as an authentication token. + """ + self._auth = value + + @property + def successful_authenticator(self): + """ + Return the instance of the authentication instance class that was used + to authenticate the request, or `None`. + """ + if not hasattr(self, "_authenticator"): + self._authenticate() + return self._authenticator + + def _load_data_and_files(self): + """ + Parses the request content into self.DATA and self.FILES. + """ + if not _hasattr(self, "_content_type"): + self._load_method_and_content_type() + + if not _hasattr(self, "_data"): + self._data, self._files = self._parse() + + def _load_method_and_content_type(self): + """ + Sets the method and content_type, and then check if they"ve + been overridden. + """ + self._content_type = self.META.get("HTTP_CONTENT_TYPE", + self.META.get("CONTENT_TYPE", "")) + + self._perform_form_overloading() + + if not _hasattr(self, "_method"): + self._method = self._request.method + + # Allow X-HTTP-METHOD-OVERRIDE header + self._method = self.META.get("HTTP_X_HTTP_METHOD_OVERRIDE", + self._method) + + def _load_stream(self): + """ + Return the content body of the request, as a stream. + """ + try: + content_length = int(self.META.get("CONTENT_LENGTH", + self.META.get("HTTP_CONTENT_LENGTH"))) + except (ValueError, TypeError): + content_length = 0 + + if content_length == 0: + self._stream = None + elif hasattr(self._request, "read"): + self._stream = self._request + else: + self._stream = six.BytesIO(self.raw_post_data) + + def _perform_form_overloading(self): + """ + If this is a form POST request, then we need to check if the method and + content/content_type have been overridden by setting them in hidden + form fields or not. + """ + + USE_FORM_OVERLOADING = ( + self._METHOD_PARAM or + (self._CONTENT_PARAM and self._CONTENTTYPE_PARAM) + ) + + # We only need to use form overloading on form POST requests. + if (not USE_FORM_OVERLOADING + or self._request.method != "POST" + or not is_form_media_type(self._content_type)): + return + + # At this point we"re committed to parsing the request as form data. + self._data = self._request.POST + self._files = self._request.FILES + + # Method overloading - change the method and remove the param from the content. + if (self._METHOD_PARAM and + self._METHOD_PARAM in self._data): + self._method = self._data[self._METHOD_PARAM].upper() + + # Content overloading - modify the content type, and force re-parse. + if (self._CONTENT_PARAM and + self._CONTENTTYPE_PARAM and + self._CONTENT_PARAM in self._data and + self._CONTENTTYPE_PARAM in self._data): + self._content_type = self._data[self._CONTENTTYPE_PARAM] + self._stream = six.BytesIO(self._data[self._CONTENT_PARAM].encode(self.parser_context["encoding"])) + self._data, self._files = (Empty, Empty) + + def _parse(self): + """ + Parse the request content, returning a two-tuple of (data, files) + + May raise an `UnsupportedMediaType`, or `ParseError` exception. + """ + stream = self.stream + media_type = self.content_type + + if stream is None or media_type is None: + empty_data = QueryDict("", self._request._encoding) + empty_files = MultiValueDict() + return (empty_data, empty_files) + + parser = self.negotiator.select_parser(self, self.parsers) + + if not parser: + raise exceptions.UnsupportedMediaType(media_type) + + try: + parsed = parser.parse(stream, media_type, self.parser_context) + except: + # If we get an exception during parsing, fill in empty data and + # re-raise. Ensures we don't simply repeat the error when + # attempting to render the browsable renderer response, or when + # logging the request or similar. + self._data = QueryDict("", self._request._encoding) + self._files = MultiValueDict() + raise + + # Parser classes may return the raw data, or a + # DataAndFiles object. Unpack the result as required. + try: + return (parsed.data, parsed.files) + except AttributeError: + empty_files = MultiValueDict() + return (parsed, empty_files) + + def _authenticate(self): + """ + Attempt to authenticate the request using each authentication instance + in turn. + Returns a three-tuple of (authenticator, user, authtoken). + """ + for authenticator in self.authenticators: + try: + user_auth_tuple = authenticator.authenticate(self) + except exceptions.APIException: + self._not_authenticated() + raise + + if not user_auth_tuple is None: + self._authenticator = authenticator + self._user, self._auth = user_auth_tuple + return + + self._not_authenticated() + + def _not_authenticated(self): + """ + Return a three-tuple of (authenticator, user, authtoken), representing + an unauthenticated request. + + By default this will be (None, AnonymousUser, None). + """ + self._authenticator = None + + if api_settings.UNAUTHENTICATED_USER: + self._user = api_settings.UNAUTHENTICATED_USER() + else: + self._user = None + + if api_settings.UNAUTHENTICATED_TOKEN: + self._auth = api_settings.UNAUTHENTICATED_TOKEN() + else: + self._auth = None + + def __getattr__(self, attr): + """ + Proxy other attributes to the underlying HttpRequest object. + """ + return getattr(self._request, attr) diff --git a/taiga/base/api/reverse.py b/taiga/base/api/reverse.py new file mode 100644 index 000000000..3a40516de --- /dev/null +++ b/taiga/base/api/reverse.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Provide reverse functions that return fully qualified URLs +""" +from django.urls import reverse as django_reverse +from django.utils.functional import lazy + + +def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra): + """ + Same as `django.core.urlresolvers`, but optionally takes a request + and returns a fully qualified URL, using the request to get the base URL. + """ + if format is not None: + kwargs = kwargs or {} + kwargs["format"] = format + url = django_reverse(viewname, args=args, kwargs=kwargs, **extra) + if request: + return request.build_absolute_uri(url) + return url + + +reverse_lazy = lazy(reverse, str) diff --git a/taiga/base/api/serializers.py b/taiga/base/api/serializers.py new file mode 100644 index 000000000..22482311b --- /dev/null +++ b/taiga/base/api/serializers.py @@ -0,0 +1,1265 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +""" +Serializers and ModelSerializers are similar to Forms and ModelForms. +Unlike forms, they are not constrained to dealing with HTML output, and +form encoded input. + +Serialization in REST framework is a two-phase process: + +1. Serializers marshal between complex types like model instances, and +python primitives. +2. The process of marshalling between python primitives and request and +response content is handled by parsers and renderers. +""" +from decimal import Decimal +from django.apps import apps +from django.core.paginator import Page +from django.db import models +from django.forms import widgets +import six +from django.utils.translation import gettext as _ + +from .settings import api_settings + +from collections import OrderedDict +import copy +import datetime +import inspect +import types +import serpy + +# Note: We do the following so that users of the framework can use this style: +# +# example_field = serializers.CharField(...) +# +# This helps keep the separation between model fields, form fields, and +# serializer fields more explicit. + +from taiga.base.exceptions import ValidationError + +from .relations import * +from .fields import * + + +def _resolve_model(obj): + """ + Resolve supplied `obj` to a Django model class. + + `obj` must be a Django model class itself, or a string + representation of one. Useful in situtations like GH #1225 where + Django may not have resolved a string-based reference to a model in + another model's foreign key definition. + + String representations should have the format: + 'appname.ModelName' + """ + if type(obj) == str and len(obj.split(".")) == 2: + app_name, model_name = obj.split(".") + return apps.get_model(app_name, model_name) + elif inspect.isclass(obj) and issubclass(obj, models.Model): + return obj + else: + raise ValueError("{0} is not a Django model".format(obj)) + + +def pretty_name(name): + """Converts 'first_name' to 'First name'""" + if not name: + return "" + return name.replace("_", " ").capitalize() + + +class RelationsList(list): + _deleted = [] + + +class NestedValidationError(ValidationError): + """ + The default ValidationError behavior is to stringify each item in the list + if the messages are a list of error messages. + + In the case of nested serializers, where the parent has many children, + then the child's `serializer.errors` will be a list of dicts. In the case + of a single child, the `serializer.errors` will be a dict. + + We need to override the default behavior to get properly nested error dicts. + """ + + def __init__(self, message): + if isinstance(message, dict): + self._messages = [message] + else: + self._messages = message + + @property + def messages(self): + return self._messages + + +class DictWithMetadata(dict): + """ + A dict-like object, that can have additional properties attached. + """ + def __getstate__(self): + """ + Used by pickle (e.g., caching). + Overridden to remove the metadata from the dict, since it shouldn't be + pickled and may in some instances be unpickleable. + """ + return dict(self) + + +class OrderedDictWithMetadata(OrderedDict): + """ + A sorted dict-like object, that can have additional properties attached. + """ + def __getstate__(self): + """ + Used by pickle (e.g., caching). + Overriden to remove the metadata from the dict, since it shouldn't be + pickle and may in some instances be unpickleable. + """ + return OrderedDict(self).__dict__ + + +def _is_protected_type(obj): + """ + True if the object is a native datatype that does not need to + be serialized further. + """ + return obj is None or isinstance(obj, ( + int, + datetime.datetime, datetime.date, datetime.time, + float, Decimal, + str) + ) + + +def _get_declared_fields(bases, attrs): + """ + Create a list of serializer field instances from the passed in "attrs", + plus any fields on the base classes (in "bases"). + + Note that all fields from the base classes are used. + """ + fields = [(field_name, attrs.pop(field_name)) + for field_name, obj in list(six.iteritems(attrs)) + if isinstance(obj, Field)] + fields.sort(key=lambda x: x[1].creation_counter) + + # If this class is subclassing another Serializer, add that Serializer's + # fields. Note that we loop over the bases in *reverse*. This is necessary + # in order to maintain the correct order of fields. + for base in bases[::-1]: + if hasattr(base, "base_fields"): + fields = list(base.base_fields.items()) + fields + + return OrderedDict(fields) + + +class SerializerMetaclass(type): + def __new__(cls, name, bases, attrs): + attrs["base_fields"] = _get_declared_fields(bases, attrs) + return super(SerializerMetaclass, cls).__new__(cls, name, bases, attrs) + + +class SerializerOptions(object): + """ + Meta class options for Serializer + """ + def __init__(self, meta): + self.depth = getattr(meta, "depth", 0) + self.fields = getattr(meta, "fields", ()) + self.exclude = getattr(meta, "exclude", ()) + + +class BaseSerializer(WritableField): + """ + This is the Serializer implementation. + We need to implement it as `BaseSerializer` due to metaclass magicks. + """ + class Meta(object): + pass + + _options_class = SerializerOptions + _dict_class = OrderedDictWithMetadata + + def __init__(self, instance=None, data=None, files=None, + context=None, partial=False, many=None, + allow_add_remove=False, **kwargs): + super(BaseSerializer, self).__init__(**kwargs) + self.opts = self._options_class(self.Meta) + self.parent = None + self.root = None + self.partial = partial + self.many = many + self.allow_add_remove = allow_add_remove + + self.context = context or {} + + self.init_data = data + self.init_files = files + self.object = instance + self.fields = self.get_fields() + + self._data = None + self._files = None + self._errors = None + + if many and instance is not None and not hasattr(instance, "__iter__"): + raise ValueError("instance should be a queryset or other iterable with many=True") + + if allow_add_remove and not many: + raise ValueError("allow_add_remove should only be used for bulk updates, but you have not set many=True") + + ##### + # Methods to determine which fields to use when (de)serializing objects. + + def get_default_fields(self): + """ + Return the complete set of default fields for the object, as a dict. + """ + return {} + + def get_fields(self): + """ + Returns the complete set of fields for the object as a dict. + + This will be the set of any explicitly declared fields, + plus the set of fields returned by get_default_fields(). + """ + ret = OrderedDict() + + # Get the explicitly declared fields + base_fields = copy.deepcopy(self.base_fields) + for key, field in base_fields.items(): + ret[key] = field + + # Add in the default fields + default_fields = self.get_default_fields() + for key, val in default_fields.items(): + if key not in ret: + ret[key] = val + + # If "fields" is specified, use those fields, in that order. + if self.opts.fields: + assert isinstance(self.opts.fields, (list, tuple)), "`fields` must be a list or tuple" + new = OrderedDict() + for key in self.opts.fields: + new[key] = ret[key] + ret = new + + # Remove anything in "exclude" + if self.opts.exclude: + assert isinstance(self.opts.exclude, (list, tuple)), "`exclude` must be a list or tuple" + for key in self.opts.exclude: + ret.pop(key, None) + + for key, field in ret.items(): + field.initialize(parent=self, field_name=key) + + return ret + + ##### + # Methods to convert or revert from objects <--> primitive representations. + + def get_field_key(self, field_name): + """ + Return the key that should be used for a given field. + """ + return field_name + + def restore_fields(self, data, files): + """ + Core of deserialization, together with `restore_object`. + Converts a dictionary of data into a dictionary of deserialized fields. + """ + reverted_data = {} + + if data is not None and not isinstance(data, dict): + self._errors["non_field_errors"] = [_("Invalid data")] + return None + + for field_name, field in self.fields.items(): + field.initialize(parent=self, field_name=field_name) + try: + field.field_from_native(data, files, field_name, reverted_data) + except ValidationError as err: + self._errors[field_name] = list(err.messages) + + return reverted_data + + def perform_validation(self, attrs): + """ + Run `validate_()` and `validate()` methods on the serializer + """ + for field_name, field in self.fields.items(): + if field_name in self._errors: + continue + + source = field.source or field_name + if self.partial and source not in attrs: + continue + try: + validate_method = getattr(self, "validate_%s" % field_name, None) + if validate_method: + attrs = validate_method(attrs, source) + except ValidationError as err: + self._errors[field_name] = self._errors.get(field_name, []) + list(err.messages) + + # If there are already errors, we don't run .validate() because + # field-validation failed and thus `attrs` may not be complete. + # which in turn can cause inconsistent validation errors. + if not self._errors: + try: + attrs = self.validate(attrs) + except ValidationError as err: + if hasattr(err, "message_dict"): + for field_name, error_messages in err.message_dict.items(): + self._errors[field_name] = self._errors.get(field_name, []) + list(error_messages) + elif hasattr(err, "messages"): + self._errors["non_field_errors"] = err.messages + + return attrs + + def validate(self, attrs): + """ + Stub method, to be overridden in Serializer subclasses + """ + return attrs + + def restore_object(self, attrs, instance=None): + """ + Deserialize a dictionary of attributes into an object instance. + You should override this method to control how deserialized objects + are instantiated. + """ + if instance is not None: + instance.update(attrs) + return instance + return attrs + + def to_native(self, obj): + """ + Serialize objects -> primitives. + """ + ret = self._dict_class() + ret.fields = self._dict_class() + ret.empty = obj is None + + for field_name, field in self.fields.items(): + field.initialize(parent=self, field_name=field_name) + key = self.get_field_key(field_name) + ret.fields[key] = field + + if obj is not None: + value = field.field_to_native(obj, field_name) + ret[key] = value + + return ret + + def from_native(self, data, files=None): + """ + Deserialize primitives -> objects. + """ + self._errors = {} + + if data is not None or files is not None: + attrs = self.restore_fields(data, files) + if attrs is not None: + attrs = self.perform_validation(attrs) + else: + self._errors["non_field_errors"] = [_("No input provided")] + + if not self._errors: + return self.restore_object(attrs, instance=getattr(self, "object", None)) + + def augment_field(self, field, field_name, key, value): + # This horrible stuff is to manage serializers rendering to HTML + field._errors = self._errors.get(key) if self._errors else None + field._name = field_name + field._value = self.init_data.get(key) if self._errors and self.init_data else value + if not field.label: + field.label = pretty_name(key) + return field + + def field_to_native(self, obj, field_name): + """ + Override default so that the serializer can be used as a nested field + across relationships. + """ + if self.write_only: + return None + + if self.source == "*": + return self.to_native(obj) + + # Get the raw field value + try: + source = self.source or field_name + value = obj + + for component in source.split("."): + if value is None: + break + value = get_component(value, component) + except ObjectDoesNotExist: + return None + + if is_simple_callable(getattr(value, "all", None)): + return [self.to_native(item) for item in value.all()] + + if value is None: + return None + + if self.many is not None: + many = self.many + else: + many = hasattr(value, "__iter__") and not isinstance(value, (Page, dict, six.text_type)) + + if many: + try: + return [self.to_native(item) for item in value] + except TypeError: + pass # LazyObject is iterable so we need to catch this + return self.to_native(value) + + def field_from_native(self, data, files, field_name, into): + """ + Override default so that the serializer can be used as a writable + nested field across relationships. + """ + if self.read_only: + return + + try: + value = data[field_name] + except KeyError: + if self.default is not None and not self.partial: + # Note: partial updates shouldn't set defaults + value = copy.deepcopy(self.default) + else: + if self.required: + raise ValidationError(self.error_messages["required"]) + return + + # Set the serializer object if it exists + obj = get_component(self.parent.object, self.source or field_name) if self.parent.object else None + + # If we have a model manager or similar object then we need + # to iterate through each instance. + if (self.many and + not hasattr(obj, "__iter__") and + is_simple_callable(getattr(obj, "all", None))): + obj = obj.all() + + if self.source == "*": + if value: + reverted_data = self.restore_fields(value, {}) + if not self._errors: + into.update(reverted_data) + else: + if value in (None, ""): + into[(self.source or field_name)] = None + else: + kwargs = { + "instance": obj, + "data": value, + "context": self.context, + "partial": self.partial, + "many": self.many, + "allow_add_remove": self.allow_add_remove + } + serializer = self.__class__(**kwargs) + + if serializer.is_valid(): + into[self.source or field_name] = serializer.object + else: + # Propagate errors up to our parent + raise NestedValidationError(serializer.errors) + + def get_identity(self, data): + """ + This hook is required for bulk update. + It is used to determine the canonical identity of a given object. + + Note that the data has not been validated at this point, so we need + to make sure that we catch any cases of incorrect datatypes being + passed to this method. + """ + try: + return data.get("id", None) + except AttributeError: + return None + + @property + def errors(self): + """ + Run deserialization and return error data, + setting self.object if no errors occurred. + """ + if self._errors is None: + data, files = self.init_data, self.init_files + + if self.many is not None: + many = self.many + else: + many = hasattr(data, "__iter__") and not isinstance(data, (Page, dict, six.text_type)) + if many: + warnings.warn("Implicit list/queryset serialization is deprecated. " + "Use the `many=True` flag when instantiating the serializer.", + DeprecationWarning, stacklevel=3) + + if many: + ret = RelationsList() + errors = [] + update = self.object is not None + + if update: + # If this is a bulk update we need to map all the objects + # to a canonical identity so we can determine which + # individual object is being updated for each item in the + # incoming data + objects = self.object + identities = [self.get_identity(self.to_native(obj)) for obj in objects] + identity_to_objects = dict(zip(identities, objects)) + + if hasattr(data, "__iter__") and not isinstance(data, (dict, six.text_type)): + for item in data: + if update: + # Determine which object we"re updating + identity = self.get_identity(item) + self.object = identity_to_objects.pop(identity, None) + if self.object is None and not self.allow_add_remove: + ret.append(None) + errors.append({"non_field_errors": [_("Cannot create a new item, only existing items may be updated.")]}) + continue + + ret.append(self.from_native(item, None)) + errors.append(self._errors) + + if update and self.allow_add_remove: + ret._deleted = identity_to_objects.values() + + self._errors = any(errors) and errors or [] + else: + self._errors = {"non_field_errors": [_("Expected a list of items.")]} + else: + ret = self.from_native(data, files) + + if not self._errors: + self.object = ret + + return self._errors + + def is_valid(self): + return not self.errors + + @property + def data(self): + """ + Returns the serialized data on the serializer. + """ + if self._data is None: + obj = self.object + + if self.many is not None: + many = self.many + else: + many = hasattr(obj, "__iter__") and not isinstance(obj, (Page, dict)) + if many: + warnings.warn("Implicit list/queryset serialization is deprecated. " + "Use the `many=True` flag when instantiating the serializer.", + DeprecationWarning, stacklevel=2) + + if many: + try: + self._data = [self.to_native(item) for item in obj] + except TypeError: + self._data = self.to_native(obj) # LazyObject is iterable so we need to catch this + else: + self._data = self.to_native(obj) + + return self._data + + def save_object(self, obj, **kwargs): + obj.save(**kwargs) + + def delete_object(self, obj): + obj.delete() + + def save(self, **kwargs): + """ + Save the deserialized object and return it. + """ + # Clear cached _data, which may be invalidated by `save()` + self._data = None + + if isinstance(self.object, list): + [self.save_object(item, **kwargs) for item in self.object] + + if self.object._deleted: + [self.delete_object(item) for item in self.object._deleted] + else: + self.save_object(self.object, **kwargs) + + return self.object + + def metadata(self): + """ + Return a dictionary of metadata about the fields on the serializer. + Useful for things like responding to OPTIONS requests, or generating + API schemas for auto-documentation. + """ + return OrderedDict( + [(field_name, field.metadata()) + for field_name, field in six.iteritems(self.fields)] + ) + + +class Serializer(six.with_metaclass(SerializerMetaclass, BaseSerializer)): + def skip_field_validation(self, field, attrs, source): + return source not in attrs and (field.partial or not field.required) + + def perform_validation(self, attrs): + """ + Run `validate_()` and `validate()` methods on the serializer + """ + for field_name, field in self.fields.items(): + if field_name in self._errors: + continue + + source = field.source or field_name + if self.skip_field_validation(field, attrs, source): + continue + + try: + validate_method = getattr(self, 'validate_%s' % field_name, None) + if validate_method: + attrs = validate_method(attrs, source) + except ValidationError as err: + self._errors[field_name] = self._errors.get(field_name, []) + list(err.messages) + + # If there are already errors, we don't run .validate() because + # field-validation failed and thus `attrs` may not be complete. + # which in turn can cause inconsistent validation errors. + if not self._errors: + try: + attrs = self.validate(attrs) + except ValidationError as err: + if hasattr(err, 'message_dict'): + for field_name, error_messages in err.message_dict.items(): + self._errors[field_name] = self._errors.get(field_name, []) + list(error_messages) + elif hasattr(err, 'messages'): + self._errors['non_field_errors'] = err.messages + + return attrs + + +class ModelSerializerOptions(SerializerOptions): + """ + Meta class options for ModelSerializer + """ + def __init__(self, meta): + super(ModelSerializerOptions, self).__init__(meta) + self.model = getattr(meta, "model", None) + self.i18n_fields = getattr(meta, "i18n_fields", ()) + self.read_only_fields = getattr(meta, "read_only_fields", ()) + self.write_only_fields = getattr(meta, "write_only_fields", ()) + + +class ModelSerializer((six.with_metaclass(SerializerMetaclass, BaseSerializer))): + """ + A serializer that deals with model instances and querysets. + """ + _options_class = ModelSerializerOptions + + field_mapping = { + models.AutoField: IntegerField, + models.FloatField: FloatField, + models.IntegerField: IntegerField, + models.PositiveIntegerField: IntegerField, + models.SmallIntegerField: IntegerField, + models.PositiveSmallIntegerField: IntegerField, + models.DateTimeField: DateTimeField, + models.DateField: DateField, + models.TimeField: TimeField, + models.DecimalField: DecimalField, + models.EmailField: EmailField, + models.CharField: CharField, + models.URLField: URLField, + models.SlugField: SlugField, + models.TextField: CharField, + models.CommaSeparatedIntegerField: CharField, + models.BooleanField: BooleanField, + models.NullBooleanField: BooleanField, + models.FileField: FileField, + models.ImageField: ImageField, + } + + def get_default_fields(self): + """ + Return all the fields that should be serialized for the model. + """ + + cls = self.opts.model + assert cls is not None, \ + "Serializer class '%s' is missing `model` Meta option" % self.__class__.__name__ + opts = cls._meta.concrete_model._meta + ret = OrderedDict() + nested = bool(self.opts.depth) + + # Deal with adding the primary key field + pk_field = opts.pk + while pk_field.remote_field and pk_field.remote_field.parent_link: + # If model is a child via multitable inheritance, use parent's pk + pk_field = pk_field.remote_field.model._meta.pk + + field = self.get_pk_field(pk_field) + if field: + ret[pk_field.name] = field + + # Deal with forward relationships + forward_rels = [field for field in opts.fields if field.serialize] + forward_rels += [field for field in opts.many_to_many if field.serialize] + + for model_field in forward_rels: + has_through_model = False + + if model_field.remote_field: + to_many = isinstance(model_field, + models.fields.related.ManyToManyField) + related_model = _resolve_model(model_field.remote_field.model) + + if to_many and not model_field.remote_field.through._meta.auto_created: + has_through_model = True + + if model_field.remote_field and nested: + if len(inspect.getfullargspec(self.get_nested_field).args) == 2: + warnings.warn( + "The `get_nested_field(model_field)` call signature " + "is due to be deprecated. " + "Use `get_nested_field(model_field, related_model, " + "to_many) instead", + PendingDeprecationWarning + ) + field = self.get_nested_field(model_field) + else: + field = self.get_nested_field(model_field, related_model, to_many) + elif model_field.remote_field: + if len(inspect.getfullargspec(self.get_nested_field).args) == 3: + warnings.warn( + "The `get_related_field(model_field, to_many)` call " + "signature is due to be deprecated. " + "Use `get_related_field(model_field, related_model, " + "to_many) instead", + PendingDeprecationWarning + ) + field = self.get_related_field(model_field, to_many=to_many) + else: + field = self.get_related_field(model_field, related_model, to_many) + else: + field = self.get_field(model_field) + + if field: + if has_through_model: + field.read_only = True + + ret[model_field.name] = field + + # Deal with reverse relationships + if not self.opts.fields: + reverse_rels = [] + else: + # Reverse relationships are only included if they are explicitly + # present in the `fields` option on the serializer + + # NOTE: Rewrite after Django 1.10 upgrade. + # See https://docs.djangoproject.com/es/1.10/ref/models/meta/#migrating-from-the-old-api + reverse_rels = [ + f for f in opts.get_fields() + if (f.one_to_many or f.one_to_one) + and f.auto_created and not f.concrete + ] + reverse_rels += [ + f for f in opts.get_fields(include_hidden=True) + if f.many_to_many and f.auto_created + ] + + for relation in reverse_rels: + accessor_name = relation.get_accessor_name() + if not self.opts.fields or accessor_name not in self.opts.fields: + continue + related_model = relation.model + to_many = relation.field.remote_field.multiple + has_through_model = False + is_m2m = isinstance(relation.field, + models.fields.related.ManyToManyField) + + if (is_m2m and + hasattr(relation.field.remote_field, "through") and + not relation.field.remote_field.through._meta.auto_created): + has_through_model = True + + if nested: + field = self.get_nested_field(None, related_model, to_many) + else: + field = self.get_related_field(None, related_model, to_many) + + if field: + if has_through_model: + field.read_only = True + + ret[accessor_name] = field + + # Add the `read_only` flag to any fields that have been specified + # in the `read_only_fields` option + for field_name in self.opts.read_only_fields: + assert field_name not in self.base_fields.keys(), ( + "field '%s' on serializer '%s' specified in " + "`read_only_fields`, but also added " + "as an explicit field. Remove it from `read_only_fields`." % + (field_name, self.__class__.__name__)) + assert field_name in ret, ( + "Non-existant field '%s' specified in `read_only_fields` " + "on serializer '%s'." % + (field_name, self.__class__.__name__)) + ret[field_name].read_only = True + + for field_name in self.opts.write_only_fields: + assert field_name not in self.base_fields.keys(), ( + "field '%s' on serializer '%s' specified in " + "`write_only_fields`, but also added " + "as an explicit field. Remove it from `write_only_fields`." % + (field_name, self.__class__.__name__)) + assert field_name in ret, ( + "Non-existant field '%s' specified in `write_only_fields` " + "on serializer '%s'." % + (field_name, self.__class__.__name__)) + ret[field_name].write_only = True + + # Add the `i18n` flag to any fields that have been specified + # in the `i18n_fields` option + for field_name in self.opts.i18n_fields: + ret[field_name].i18n = True + + return ret + + def get_pk_field(self, model_field): + """ + Returns a default instance of the pk field. + """ + return self.get_field(model_field) + + def get_nested_field(self, model_field, related_model, to_many): + """ + Creates a default instance of a nested relational field. + + Note that model_field will be `None` for reverse relationships. + """ + class NestedModelSerializer(ModelSerializer): + class Meta: + model = related_model + depth = self.opts.depth - 1 + + return NestedModelSerializer(many=to_many) + + def get_related_field(self, model_field, related_model, to_many): + """ + Creates a default instance of a flat relational field. + + Note that model_field will be `None` for reverse relationships. + """ + # TODO: filter queryset using: + # .using(db).complex_filter(self.rel.limit_choices_to) + + kwargs = { + "queryset": related_model._default_manager, + "many": to_many + } + + if model_field: + kwargs["required"] = not(model_field.null or model_field.blank) + + return PrimaryKeyRelatedField(**kwargs) + + def get_field(self, model_field): + """ + Creates a default instance of a basic non-relational field. + """ + kwargs = {} + + if model_field.null or model_field.blank: + kwargs["required"] = False + + if isinstance(model_field, models.AutoField) or not model_field.editable: + kwargs["read_only"] = True + + if model_field.has_default(): + kwargs["default"] = model_field.get_default() + + if issubclass(model_field.__class__, models.TextField): + kwargs["widget"] = widgets.Textarea + + if model_field.verbose_name is not None: + kwargs["label"] = model_field.verbose_name + + if model_field.help_text is not None: + kwargs["help_text"] = model_field.help_text + + # TODO: TypedChoiceField? + if model_field.flatchoices: # This ModelField contains choices + kwargs["choices"] = model_field.flatchoices + if model_field.null: + kwargs["empty"] = None + return ChoiceField(**kwargs) + + # put this below the ChoiceField because min_value isn't a valid initializer + if issubclass(model_field.__class__, models.PositiveIntegerField) or\ + issubclass(model_field.__class__, models.PositiveSmallIntegerField): + kwargs["min_value"] = 0 + + attribute_dict = { + models.CharField: ["max_length"], + models.CommaSeparatedIntegerField: ["max_length"], + models.DecimalField: ["max_digits", "decimal_places"], + models.EmailField: ["max_length"], + models.FileField: ["max_length"], + models.ImageField: ["max_length"], + models.SlugField: ["max_length"], + models.URLField: ["max_length"], + } + + if model_field.__class__ in attribute_dict: + attributes = attribute_dict[model_field.__class__] + for attribute in attributes: + kwargs.update({attribute: getattr(model_field, attribute)}) + + if model_field.name in self.opts.i18n_fields: + kwargs["i18n"] = True + + try: + return self.field_mapping[model_field.__class__](**kwargs) + except KeyError: + return ModelField(model_field=model_field, **kwargs) + + def perform_validation(self, attrs): + for attr in attrs: + field = self.fields.get(attr, None) + if field: + field.required = True + return super().perform_validation(attrs) + + def get_validation_exclusions(self): + """ + Return a list of field names to exclude from model validation. + """ + cls = self.opts.model + opts = cls._meta.concrete_model._meta + exclusions = [field.name for field in opts.fields + opts.many_to_many] + + for field_name, field in self.fields.items(): + field_name = field.source or field_name + if field_name in exclusions \ + and not field.read_only \ + and field.required \ + and not isinstance(field, Serializer): + exclusions.remove(field_name) + return exclusions + + def full_clean(self, instance): + """ + Perform Django's full_clean, and populate the `errors` dictionary + if any validation errors occur. + + Note that we don't perform this inside the `.restore_object()` method, + so that subclasses can override `.restore_object()`, and still get + the full_clean validation checking. + """ + try: + instance.full_clean(exclude=self.get_validation_exclusions()) + except ValidationError as err: + self._errors = err.message_dict + return None + return instance + + def restore_object(self, attrs, instance=None): + """ + Restore the model instance. + """ + m2m_data = {} + related_data = {} + nested_forward_relations = {} + model = self.opts.model + meta = self.opts.model._meta + + # Reverse fk or one-to-one relations + # NOTE: Rewrite after Django 1.10 upgrade. + # See https://docs.djangoproject.com/es/1.10/ref/models/meta/#migrating-from-the-old-api + related_objes_with_models = [ + (f, f.model if f.model != model else None) + for f in meta.get_fields() + if (f.one_to_many or f.one_to_one) + and f.auto_created and not f.concrete + ] + for (obj, model) in related_objes_with_models: + field_name = obj.get_accessor_name() + if field_name in attrs: + related_data[field_name] = attrs.pop(field_name) + + # Reverse m2m relations + # NOTE: Rewrite after Django 1.10 upgrade. + # See https://docs.djangoproject.com/es/1.10/ref/models/meta/#migrating-from-the-old-api + related_m2m_objects_with_model = [ + (f, f.model if f.model != model else None) + for f in meta.get_fields(include_hidden=True) + if f.many_to_many and f.auto_created + ] + for (obj, model) in related_m2m_objects_with_model: + field_name = obj.get_accessor_name() + if field_name in attrs: + m2m_data[field_name] = attrs.pop(field_name) + + # Forward m2m relations + for field in list(meta.many_to_many) + meta.private_fields: + if field.name in attrs: + m2m_data[field.name] = attrs.pop(field.name) + + # Nested forward relations - These need to be marked so we can save + # them before saving the parent model instance. + for field_name in attrs.keys(): + if isinstance(self.fields.get(field_name, None), Serializer): + nested_forward_relations[field_name] = attrs[field_name] + + # Update an existing instance... + if instance is not None: + for key, val in attrs.items(): + try: + setattr(instance, key, val) + except ValueError: + self._errors[key] = self.error_messages["required"] + + # ...or create a new instance + else: + instance = self.opts.model(**attrs) + + # Any relations that cannot be set until we"ve + # saved the model get hidden away on these + # private attributes, so we can deal with them + # at the point of save. + instance._related_data = related_data + instance._m2m_data = m2m_data + instance._nested_forward_relations = nested_forward_relations + + return instance + + def from_native(self, data, files): + """ + Override the default method to also include model field validation. + """ + instance = super(ModelSerializer, self).from_native(data, files) + if not self._errors: + return self.full_clean(instance) + + def save(self, **kwargs): + """ + Due to DRF bug with M2M fields we refresh object state from database + directly if object is models.Model type and it contains m2m fields + + See: https://github.com/tomchristie/django-rest-framework/issues/1556 + """ + self.object = super().save(**kwargs) + model = self.Meta.model + if model._meta.model._meta.local_many_to_many and self.object.pk: + self.object = model.objects.get(pk=self.object.pk) + return self.object + + def save_object(self, obj, **kwargs): + """ + Save the deserialized object. + """ + if getattr(obj, "_nested_forward_relations", None): + # Nested relationships need to be saved before we can save the + # parent instance. + for field_name, sub_object in obj._nested_forward_relations.items(): + if sub_object: + self.save_object(sub_object) + setattr(obj, field_name, sub_object) + + obj.save(**kwargs) + + if getattr(obj, "_m2m_data", None): + for accessor_name, object_list in obj._m2m_data.items(): + field = getattr(obj, accessor_name) + field.set(object_list) + del(obj._m2m_data) + + if getattr(obj, "_related_data", None): + related_fields = dict([ + (field.get_accessor_name(), field) + for field, model + in obj._meta.get_all_related_objects_with_model() + ]) + for accessor_name, related in obj._related_data.items(): + if isinstance(related, RelationsList): + # Nested reverse fk relationship + for related_item in related: + fk_field = related_fields[accessor_name].field.name + setattr(related_item, fk_field, obj) + self.save_object(related_item) + + # Delete any removed objects + if related._deleted: + [self.delete_object(item) for item in related._deleted] + + elif isinstance(related, models.Model): + # Nested reverse one-one relationship + fk_field = obj._meta.get_field_by_name(accessor_name)[0].field.name + setattr(related, fk_field, obj) + self.save_object(related) + else: + # Reverse FK or reverse one-one + setattr(obj, accessor_name, related) + del(obj._related_data) + + +class HyperlinkedModelSerializerOptions(ModelSerializerOptions): + """ + Options for HyperlinkedModelSerializer + """ + def __init__(self, meta): + super(HyperlinkedModelSerializerOptions, self).__init__(meta) + self.view_name = getattr(meta, "view_name", None) + self.lookup_field = getattr(meta, "lookup_field", None) + self.url_field_name = getattr(meta, "url_field_name", api_settings.URL_FIELD_NAME) + + +class HyperlinkedModelSerializer(ModelSerializer): + """ + A subclass of ModelSerializer that uses hyperlinked relationships, + instead of primary key relationships. + """ + _options_class = HyperlinkedModelSerializerOptions + _default_view_name = "%(model_name)s-detail" + _hyperlink_field_class = HyperlinkedRelatedField + _hyperlink_identify_field_class = HyperlinkedIdentityField + + def get_default_fields(self): + fields = super(HyperlinkedModelSerializer, self).get_default_fields() + + if self.opts.view_name is None: + self.opts.view_name = self._get_default_view_name(self.opts.model) + + if self.opts.url_field_name not in fields: + url_field = self._hyperlink_identify_field_class( + view_name=self.opts.view_name, + lookup_field=self.opts.lookup_field + ) + ret = self._dict_class() + ret[self.opts.url_field_name] = url_field + ret.update(fields) + fields = ret + + return fields + + def get_pk_field(self, model_field): + if self.opts.fields and model_field.name in self.opts.fields: + return self.get_field(model_field) + + def get_related_field(self, model_field, related_model, to_many): + """ + Creates a default instance of a flat relational field. + """ + # TODO: filter queryset using: + # .using(db).complex_filter(self.rel.limit_choices_to) + kwargs = { + "queryset": related_model._default_manager, + "view_name": self._get_default_view_name(related_model), + "many": to_many + } + + if model_field: + kwargs["required"] = not(model_field.null or model_field.blank) + + if self.opts.lookup_field: + kwargs["lookup_field"] = self.opts.lookup_field + + return self._hyperlink_field_class(**kwargs) + + def get_identity(self, data): + """ + This hook is required for bulk update. + We need to override the default, to use the url as the identity. + """ + try: + return data.get(self.opts.url_field_name, None) + except AttributeError: + return None + + def _get_default_view_name(self, model): + """ + Return the view name to use if `view_name` is not specified in `Meta` + """ + model_meta = model._meta + format_kwargs = { + "app_label": model_meta.app_label, + "model_name": model_meta.object_name.lower() + } + return self._default_view_name % format_kwargs + + +class LightSerializer(serpy.Serializer): + def __init__(self, *args, **kwargs): + kwargs.pop("read_only", None) + kwargs.pop("partial", None) + kwargs.pop("files", None) + context = kwargs.pop("context", {}) + view = kwargs.pop("view", {}) + super().__init__(*args, **kwargs) + self.context = context + self.view = view + + +class LightDictSerializer(serpy.DictSerializer): + def __init__(self, *args, **kwargs): + kwargs.pop("read_only", None) + kwargs.pop("partial", None) + kwargs.pop("files", None) + context = kwargs.pop("context", {}) + view = kwargs.pop("view", {}) + super().__init__(*args, **kwargs) + self.context = context + self.view = view diff --git a/taiga/base/api/settings.py b/taiga/base/api/settings.py new file mode 100644 index 000000000..aabb0dbc2 --- /dev/null +++ b/taiga/base/api/settings.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +""" +Settings for REST framework are all namespaced in the REST_FRAMEWORK setting. +For example your project's `settings.py` file might look like this: + +REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": ( + "taiga.base.api.renderers.JSONRenderer", + ) + "DEFAULT_PARSER_CLASSES": ( + "taiga.base.api.parsers.JSONParser", + ) +} + +This module provides the `api_setting` object, that is used to access +REST framework settings, checking for user settings first, then falling +back to the defaults. +""" +from __future__ import unicode_literals + +from django.conf import settings +import six + +import importlib + +from . import ISO_8601 + + +USER_SETTINGS = getattr(settings, "REST_FRAMEWORK", None) + +DEFAULTS = { + # Base API policies + "DEFAULT_RENDERER_CLASSES": ( + "taiga.base.api.renderers.JSONRenderer", + ), + "DEFAULT_PARSER_CLASSES": ( + "taiga.base.api.parsers.JSONParser", + "taiga.base.api.parsers.FormParser", + "taiga.base.api.parsers.MultiPartParser" + ), + "DEFAULT_AUTHENTICATION_CLASSES": ( + "taiga.base.api.authentication.SessionAuthentication", + "taiga.base.api.authentication.BasicAuthentication" + ), + "DEFAULT_PERMISSION_CLASSES": ( + "taiga.base.api.permissions.AllowAny", + ), + "DEFAULT_THROTTLE_CLASSES": ( + ), + "DEFAULT_CONTENT_NEGOTIATION_CLASS": + "taiga.base.api.negotiation.DefaultContentNegotiation", + + # Genric view behavior + "DEFAULT_MODEL_SERIALIZER_CLASS": + "taiga.base.api.serializers.ModelSerializer", + "DEFAULT_MODEL_VALIDATOR_CLASS": + "taiga.base.api.validators.ModelValidator", + "DEFAULT_FILTER_BACKENDS": (), + + # Throttling + "DEFAULT_THROTTLE_RATES": { + "user": None, + "anon": None, + }, + "DEFAULT_THROTTLE_WHITELIST": [], + + # Pagination + "PAGINATE_BY": None, + "PAGINATE_BY_PARAM": None, + "MAX_PAGINATE_BY": None, + + # Authentication + "UNAUTHENTICATED_USER": "django.contrib.auth.models.AnonymousUser", + "UNAUTHENTICATED_TOKEN": None, + + # View configuration + "VIEW_NAME_FUNCTION": "taiga.base.api.views.get_view_name", + "VIEW_DESCRIPTION_FUNCTION": "taiga.base.api.views.get_view_description", + + # Exception handling + "EXCEPTION_HANDLER": "taiga.base.api.views.exception_handler", + + # Testing + "TEST_REQUEST_RENDERER_CLASSES": ( + "taiga.base.api.renderers.MultiPartRenderer", + "taiga.base.api.renderers.JSONRenderer" + ), + "TEST_REQUEST_DEFAULT_FORMAT": "multipart", + + # Browser enhancements + "FORM_METHOD_OVERRIDE": "_method", + "FORM_CONTENT_OVERRIDE": "_content", + "FORM_CONTENTTYPE_OVERRIDE": "_content_type", + "URL_ACCEPT_OVERRIDE": "accept", + "URL_FORMAT_OVERRIDE": "format", + + "FORMAT_SUFFIX_KWARG": "format", + "URL_FIELD_NAME": "url", + + # Input and output formats + "DATE_INPUT_FORMATS": ( + ISO_8601, + ), + "DATE_FORMAT": ISO_8601, + + "DATETIME_INPUT_FORMATS": ( + ISO_8601, + ), + "DATETIME_FORMAT": None, + + "TIME_INPUT_FORMATS": ( + ISO_8601, + ), + "TIME_FORMAT": None, + + # Pending deprecation + "FILTER_BACKEND": None, +} + + +# List of settings that may be in string import notation. +IMPORT_STRINGS = ( + "DEFAULT_RENDERER_CLASSES", + "DEFAULT_PARSER_CLASSES", + "DEFAULT_AUTHENTICATION_CLASSES", + "DEFAULT_PERMISSION_CLASSES", + "DEFAULT_THROTTLE_CLASSES", + "DEFAULT_CONTENT_NEGOTIATION_CLASS", + "DEFAULT_MODEL_SERIALIZER_CLASS", + "DEFAULT_FILTER_BACKENDS", + "EXCEPTION_HANDLER", + "FILTER_BACKEND", + "TEST_REQUEST_RENDERER_CLASSES", + "UNAUTHENTICATED_USER", + "UNAUTHENTICATED_TOKEN", + "VIEW_NAME_FUNCTION", + "VIEW_DESCRIPTION_FUNCTION" +) + + +def perform_import(val, setting_name): + """ + If the given setting is a string import notation, + then perform the necessary import or imports. + """ + if isinstance(val, six.string_types): + return import_from_string(val, setting_name) + elif isinstance(val, (list, tuple)): + return [import_from_string(item, setting_name) for item in val] + return val + + +def import_from_string(val, setting_name): + """ + Attempt to import a class from a string representation. + """ + try: + # Nod to tastypie's use of importlib. + parts = val.split('.') + module_path, class_name = '.'.join(parts[:-1]), parts[-1] + module = importlib.import_module(module_path) + return getattr(module, class_name) + except ImportError as e: + msg = "Could not import '%s' for API setting '%s'. %s: %s." % (val, setting_name, e.__class__.__name__, e) + raise ImportError(msg) + + +class APISettings(object): + """ + A settings object, that allows API settings to be accessed as properties. + For example: + + from taiga.base.api.settings import api_settings + print api_settings.DEFAULT_RENDERER_CLASSES + + Any setting with string import paths will be automatically resolved + and return the class, rather than the string literal. + """ + def __init__(self, user_settings=None, defaults=None, import_strings=None): + self.user_settings = user_settings or {} + self.defaults = defaults or {} + self.import_strings = import_strings or () + + def __getattr__(self, attr): + if attr not in self.defaults.keys(): + raise AttributeError("Invalid API setting: '%s'" % attr) + + try: + # Check if present in user settings + val = self.user_settings[attr] + except KeyError: + # Fall back to defaults + val = self.defaults[attr] + + # Coerce import strings into classes + if val and attr in self.import_strings: + val = perform_import(val, attr) + + self.validate_setting(attr, val) + + # Cache the result + setattr(self, attr, val) + return val + + def validate_setting(self, attr, val): + if attr == "FILTER_BACKEND" and val is not None: + # Make sure we can initialize the class + val() + +api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) diff --git a/taiga/base/api/throttling.py b/taiga/base/api/throttling.py new file mode 100644 index 000000000..47a019aa5 --- /dev/null +++ b/taiga/base/api/throttling.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Provides various throttling policies. +""" + +from django.core.cache import cache as default_cache +from django.core.exceptions import ImproperlyConfigured + +from .settings import api_settings + +import time + + +class BaseThrottle(object): + """ + Rate throttling of requests. + """ + def allow_request(self, request, view): + """ + Return `True` if the request should be allowed, `False` otherwise. + """ + raise NotImplementedError(".allow_request() must be overridden") + + def finalize(self, request, response, view): + """ + Optionally, update the Trottling information based on de response. + """ + return None + + def wait(self): + """ + Optionally, return a recommended number of seconds to wait before + the next request. + """ + return None + + +class SimpleRateThrottle(BaseThrottle): + """ + A simple cache implementation, that only requires `.get_cache_key()` + to be overridden. + + The rate (requests / seconds) is set by a `throttle` attribute on the View + class. The attribute is a string of the form "number_of_requests/period". + + Period should be one of: ("s", "sec", "m", "min", "h", "hour", "d", "day") + + Previous request information used for throttling is stored in the cache. + """ + + cache = default_cache + timer = time.time + cache_format = "throttle_%(scope)s_%(ident)s" + scope = None + THROTTLE_RATES = api_settings.DEFAULT_THROTTLE_RATES + + def __init__(self): + if not getattr(self, "rate", None): + self.rate = self.get_rate() + self.num_requests, self.duration = self.parse_rate(self.rate) + + def get_cache_key(self, request, view): + """ + Should return a unique cache-key which can be used for throttling. + Must be overridden. + + May return `None` if the request should not be throttled. + """ + raise NotImplementedError(".get_cache_key() must be overridden") + + def has_to_finalize(self, request, response, view): + """ + Determine if the finalize method must be executed. + """ + return self.rate is not None + + def get_rate(self): + """ + Determine the string representation of the allowed request rate. + """ + if not getattr(self, "scope", None): + msg = ("You must set either `.scope` or `.rate` for \"%s\" throttle" % + self.__class__.__name__) + raise ImproperlyConfigured(msg) + + try: + return self.THROTTLE_RATES[self.scope] + except KeyError: + msg = "No default throttle rate set for \"%s\" scope" % self.scope + raise ImproperlyConfigured(msg) + + def parse_rate(self, rate): + """ + Given the request rate string, return a two tuple of: + , + """ + if rate is None: + return (None, None) + num, period = rate.split("/") + num_requests = int(num) + duration = {"s": 1, "m": 60, "h": 3600, "d": 86400}[period[0]] + return (num_requests, duration) + + def allow_request(self, request, view): + """ + Implement the check to see if the request should be throttled. + + On success calls `throttle_success`. + On failure calls `throttle_failure`. + """ + if self.rate is None: + return True + + self.key = self.get_cache_key(request, view) + if self.key is None: + return True + + self.history = self.cache.get(self.key, []) + self.now = self.timer() + + # Drop any requests from the history which have now passed the + # throttle duration + while self.history and self.history[-1] <= self.now - self.duration: + self.history.pop() + + if self.exceeded_throttling_restriction(request, view): + return self.throttle_failure() + return self.throttle_success(request, view) + + def exceeded_throttling_restriction(self, request, view): + return len(self.history) >= self.num_requests + + def throttle_success(self, request, view): + """ + Inserts the current request's timestamp along with the key + into the cache. + """ + self.history.insert(0, self.now) + self.cache.set(self.key, self.history, self.duration) + return True + + def throttle_failure(self): + """ + Called when a request to the API has failed due to throttling. + """ + return False + + def wait(self): + """ + Returns the recommended next request time in seconds. + """ + if self.history: + remaining_duration = self.duration - (self.now - self.history[-1]) + else: + remaining_duration = self.duration + + available_requests = self.num_requests - len(self.history) + 1 + if available_requests <= 0: + return None + + return remaining_duration / float(available_requests) + + +class AnonRateThrottle(SimpleRateThrottle): + """ + Limits the rate of API calls that may be made by a anonymous users. + + The IP address of the request will be used as the unique cache key. + """ + scope = "anon" + + def get_cache_key(self, request, view): + if request.user.is_authenticated: + return None # Only throttle unauthenticated requests. + + ident = request.headers.get("x-forwarded-for") + if ident is None: + ident = request.META.get("REMOTE_ADDR") + + return self.cache_format % { + "scope": self.scope, + "ident": ident + } + + +class UserRateThrottle(SimpleRateThrottle): + """ + Limits the rate of API calls that may be made by a given user. + + The user id will be used as a unique cache key if the user is + authenticated. For anonymous requests, the IP address of the request will + be used. + """ + scope = "user" + + def get_cache_key(self, request, view): + if request.user.is_authenticated: + ident = request.user.id + else: + ident = request.META.get("REMOTE_ADDR", None) + + return self.cache_format % { + "scope": self.scope, + "ident": ident + } + + +class ScopedRateThrottle(SimpleRateThrottle): + """ + Limits the rate of API calls by different amounts for various parts of + the API. Any view that has the `throttle_scope` property set will be + throttled. The unique cache key will be generated by concatenating the + user id of the request, and the scope of the view being accessed. + """ + scope_attr = "throttle_scope" + + def __init__(self): + # Override the usual SimpleRateThrottle, because we can't determine + # the rate until called by the view. + pass + + def allow_request(self, request, view): + # We can only determine the scope once we"re called by the view. + self.scope = getattr(view, self.scope_attr, None) + + # If a view does not have a `throttle_scope` always allow the request + if not self.scope: + return True + + # Determine the allowed request rate as we normally would during + # the `__init__` call. + self.rate = self.get_rate() + self.num_requests, self.duration = self.parse_rate(self.rate) + + # We can now proceed as normal. + return super(ScopedRateThrottle, self).allow_request(request, view) + + def get_cache_key(self, request, view): + """ + If `view.throttle_scope` is not set, don't apply this throttle. + + Otherwise generate the unique cache key by concatenating the user id + with the ".throttle_scope` property of the view. + """ + if request.user.is_authenticated: + ident = request.user.id + else: + ident = request.META.get("REMOTE_ADDR", None) + + return self.cache_format % { + "scope": self.scope, + "ident": ident + } diff --git a/taiga/base/api/urlpatterns.py b/taiga/base/api/urlpatterns.py new file mode 100644 index 000000000..612015f0c --- /dev/null +++ b/taiga/base/api/urlpatterns.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +from django.urls import URLResolver +from django.urls import include, re_path + +from .settings import api_settings + + +def apply_suffix_patterns(urlpatterns, suffix_pattern, suffix_required): + ret = [] + for urlpattern in urlpatterns: + if isinstance(urlpattern, URLResolver): + # Set of included URL patterns + regex = urlpattern.pattern.regex.pattern + namespace = urlpattern.namespace + app_name = urlpattern.app_name + kwargs = urlpattern.default_kwargs + # Add in the included patterns, after applying the suffixes + patterns = apply_suffix_patterns(urlpattern.url_patterns, + suffix_pattern, + suffix_required) + ret.append(re_path(regex, include(patterns, namespace, app_name), kwargs)) + + else: + # Regular URL pattern + regex = urlpattern.pattern.regex.pattern.rstrip("$") + suffix_pattern + view = urlpattern.callback + kwargs = urlpattern.default_args + name = urlpattern.name + # Add in both the existing and the new urlpattern + if not suffix_required: + ret.append(urlpattern) + ret.append(re_path(regex, view, kwargs, name)) + + return ret + + +def format_suffix_patterns(urlpatterns, suffix_required=False, allowed=None): + """ + Supplement existing urlpatterns with corresponding patterns that also + include a ".format" suffix. Retains urlpattern ordering. + + urlpatterns: + A list of URL patterns. + + suffix_required: + If `True`, only suffixed URLs will be generated, and non-suffixed + URLs will not be used. Defaults to `False`. + + allowed: + An optional tuple/list of allowed suffixes. eg ["json", "api"] + Defaults to `None`, which allows any suffix. + """ + suffix_kwarg = api_settings.FORMAT_SUFFIX_KWARG + if allowed: + if len(allowed) == 1: + allowed_pattern = allowed[0] + else: + allowed_pattern = "(%s)" % "|".join(allowed) + suffix_pattern = r"\.(?P<%s>%s)$" % (suffix_kwarg, allowed_pattern) + else: + suffix_pattern = r"\.(?P<%s>[a-z0-9]+)$" % suffix_kwarg + + return apply_suffix_patterns(urlpatterns, suffix_pattern, suffix_required) diff --git a/taiga/base/api/utils/__init__.py b/taiga/base/api/utils/__init__.py new file mode 100644 index 000000000..d4b27697e --- /dev/null +++ b/taiga/base/api/utils/__init__.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from django.http import Http404 +from django.shortcuts import get_object_or_404 as _get_object_or_404 + +from taiga.base.exceptions import NotAuthenticated + + +def get_object_or_404(queryset, *filter_args, **filter_kwargs): + """ + Same as Django's standard shortcut, but make sure to raise 404 + if the filter_kwargs don't match the required types. + """ + try: + return _get_object_or_404(queryset, *filter_args, **filter_kwargs) + except (TypeError, ValueError): + raise Http404 + + +def get_object_or_error(queryset, user, *filter_args, **filter_kwargs): + """ + Same as Django's standard shortcut, but make sure to raise 404 + (if the filter_kwargs don't match the required types), or 401 + (if the user is not logged in). + + """ + try: + return _get_object_or_404(queryset, *filter_args, **filter_kwargs) + except (Http404, TypeError, ValueError): + if not user.is_authenticated: + raise NotAuthenticated + raise Http404 diff --git a/taiga/base/api/utils/encoders.py b/taiga/base/api/utils/encoders.py new file mode 100644 index 000000000..700fc7f8b --- /dev/null +++ b/taiga/base/api/utils/encoders.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Helper classes for parsers. +""" +from django.db.models.query import QuerySet +from django.utils.functional import Promise +from django.utils import timezone +# from django.utils.deprecation import CallableBool +from django.utils.encoding import force_str + +import datetime +import decimal +import types +import json + + +class JSONEncoder(json.JSONEncoder): + """ + JSONEncoder subclass that knows how to encode date/time/timedelta, + decimal types, and generators. + """ + def default(self, o): + # For Date Time string spec, see ECMA 262 + # http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 + if isinstance(o, Promise): + return force_str(o) + # elif isinstance(o, CallableBool): + # return bool(o) + elif isinstance(o, datetime.datetime): + r = o.isoformat() + if o.microsecond: + r = r[:23] + r[26:] + if r.endswith("+00:00"): + r = r[:-6] + "Z" + return r + elif isinstance(o, datetime.date): + return o.isoformat() + elif isinstance(o, datetime.time): + if timezone and timezone.is_aware(o): + raise ValueError("JSON can't represent timezone-aware times.") + r = o.isoformat() + if o.microsecond: + r = r[:12] + return r + elif isinstance(o, datetime.timedelta): + return str(o.total_seconds()) + elif isinstance(o, decimal.Decimal): + return str(o) + elif isinstance(o, QuerySet): + return list(o) + elif hasattr(o, "tolist"): + return o.tolist() + elif hasattr(o, "__getitem__"): + try: + return dict(o) + except: + pass + elif hasattr(o, "__iter__"): + return [i for i in o] + + return super(JSONEncoder, self).default(o) + + +SafeDumper = None diff --git a/taiga/base/api/utils/formatting.py b/taiga/base/api/utils/formatting.py new file mode 100644 index 000000000..c27a31172 --- /dev/null +++ b/taiga/base/api/utils/formatting.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Utility functions to return a formatted name and description for a given view. +""" +from django.utils.html import escape +from django.utils.safestring import mark_safe + +from taiga.base.api.settings import api_settings + +from textwrap import dedent +import re + +# Markdown is optional +try: + import markdown + + def apply_markdown(text): + """ + Simple wrapper around :func:`markdown.markdown` to set the base level + of '#' style headers to

. + """ + extensions = ["headerid(level=2)"] + safe_mode = False + md = markdown.Markdown(extensions=extensions, safe_mode=safe_mode) + return md.convert(text) + +except ImportError: + apply_markdown = None + + +def remove_trailing_string(content, trailing): + """ + Strip trailing component `trailing` from `content` if it exists. + Used when generating names from view classes. + """ + if content.endswith(trailing) and content != trailing: + return content[:-len(trailing)] + return content + + +def dedent(content): + """ + Remove leading indent from a block of text. + Used when generating descriptions from docstrings. + + Note that python's `textwrap.dedent` doesn't quite cut it, + as it fails to dedent multiline docstrings that include + unindented text on the initial line. + """ + whitespace_counts = [len(line) - len(line.lstrip(" ")) + for line in content.splitlines()[1:] if line.lstrip()] + + # unindent the content if needed + if whitespace_counts: + whitespace_pattern = "^" + (" " * min(whitespace_counts)) + content = re.sub(re.compile(whitespace_pattern, re.MULTILINE), "", content) + + return content.strip() + +def camelcase_to_spaces(content): + """ + Translate 'CamelCaseNames' to 'Camel Case Names'. + Used when generating names from view classes. + """ + camelcase_boundry = "(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))" + content = re.sub(camelcase_boundry, " \\1", content).strip() + return " ".join(content.split("_")).title() + +def markup_description(description): + """ + Apply HTML markup to the given description. + """ + if apply_markdown: + description = apply_markdown(description) + else: + description = escape(description).replace("\n", "
") + return mark_safe(description) diff --git a/taiga/base/api/utils/mediatypes.py b/taiga/base/api/utils/mediatypes.py new file mode 100644 index 000000000..cb96fb455 --- /dev/null +++ b/taiga/base/api/utils/mediatypes.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Handling of media types, as found in HTTP Content-Type and Accept headers. + +See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 +""" +from django.http.multipartparser import parse_header + +from taiga.base.api import HTTP_HEADER_ENCODING + + +def media_type_matches(lhs, rhs): + """ + Returns ``True`` if the media type in the first argument <= the + media type in the second argument. The media types are strings + as described by the HTTP spec. + + Valid media type strings include: + + 'application/json; indent=4' + 'application/json' + 'text/*' + '*/*' + """ + lhs = _MediaType(lhs) + rhs = _MediaType(rhs) + return lhs.match(rhs) + + +def order_by_precedence(media_type_lst): + """ + Returns a list of sets of media type strings, ordered by precedence. + Precedence is determined by how specific a media type is: + + 3. 'type/subtype; param=val' + 2. 'type/subtype' + 1. 'type/*' + 0. '*/*' + """ + ret = [set(), set(), set(), set()] + for media_type in media_type_lst: + precedence = _MediaType(media_type).precedence + ret[3 - precedence].add(media_type) + return [media_types for media_types in ret if media_types] + + +class _MediaType(object): + def __init__(self, media_type_str): + if media_type_str is None: + media_type_str = '' + self.orig = media_type_str + self.full_type, self.params = parse_header(media_type_str.encode(HTTP_HEADER_ENCODING)) + self.main_type, sep, self.sub_type = self.full_type.partition("/") + + def match(self, other): + """Return true if this MediaType satisfies the given MediaType.""" + for key in self.params.keys(): + if key != "q" and other.params.get(key, None) != self.params.get(key, None): + return False + + if self.sub_type != "*" and other.sub_type != "*" and other.sub_type != self.sub_type: + return False + + if self.main_type != "*" and other.main_type != "*" and other.main_type != self.main_type: + return False + + return True + + @property + def precedence(self): + """ + Return a precedence level from 0-3 for the media type given how specific it is. + """ + if self.main_type == "*": + return 0 + elif self.sub_type == "*": + return 1 + elif not self.params or self.params.keys() == ["q"]: + return 2 + return 3 + + def __str__(self): + return self.__unicode__().encode("utf-8") + + def __unicode__(self): + ret = "%s/%s" % (self.main_type, self.sub_type) + for key, val in self.params.items(): + ret += "; %s=%s" % (key, val) + return ret diff --git a/taiga/base/api/validators.py b/taiga/base/api/validators.py new file mode 100644 index 000000000..10de0d442 --- /dev/null +++ b/taiga/base/api/validators.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from . import serializers + + +class Validator(serializers.Serializer): + pass + + +class ModelValidator(serializers.ModelSerializer): + pass diff --git a/taiga/base/api/views.py b/taiga/base/api/views.py new file mode 100644 index 000000000..ea70a9a5e --- /dev/null +++ b/taiga/base/api/views.py @@ -0,0 +1,482 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import json + +from collections import OrderedDict + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.http import Http404, HttpResponse +from django.http.response import HttpResponseBase +from django.views.decorators.csrf import csrf_exempt +from django.views.defaults import server_error +from django.views.generic import View +from django.utils.encoding import smart_str +from django.utils.translation import gettext as _ + +from .request import Request +from .settings import api_settings +from .utils import formatting + +from taiga.base import status +from taiga.base import exceptions +from taiga.base.response import Response +from taiga.base.response import Ok +from taiga.base.response import NotFound +from taiga.base.response import Forbidden +from taiga.base.utils.iterators import as_tuple + + + +def get_view_name(view_cls, suffix=None): + """ + Given a view class, return a textual name to represent the view. + This name is used in the browsable API, and in OPTIONS responses. + + This function is the default for the `VIEW_NAME_FUNCTION` setting. + """ + name = view_cls.__name__ + name = formatting.remove_trailing_string(name, 'View') + name = formatting.remove_trailing_string(name, 'ViewSet') + name = formatting.camelcase_to_spaces(name) + if suffix: + name += ' ' + suffix + + return name + + +def get_view_description(view_cls, html=False): + """ + Given a view class, return a textual description to represent the view. + This name is used in the browsable API, and in OPTIONS responses. + + This function is the default for the `VIEW_DESCRIPTION_FUNCTION` setting. + """ + description = view_cls.__doc__ or '' + description = formatting.dedent(smart_str(description)) + if html: + return formatting.markup_description(description) + return description + + +def exception_handler(exc): + """ + Returns the response that should be used for any given exception. + + By default we handle the REST framework `APIException`, and also + Django's builtin `Http404` and `PermissionDenied` exceptions. + + Any unhandled exceptions may return `None`, which will cause a 500 error + to be raised. + """ + if isinstance(exc, exceptions.APIException): + headers = {} + if getattr(exc, 'auth_header', None): + headers['WWW-Authenticate'] = exc.auth_header + if getattr(exc, 'wait', None): + headers['X-Throttle-Wait-Seconds'] = '%d' % exc.wait + + return Response({'detail': exc.detail}, + status=exc.status_code, + headers=headers) + + elif isinstance(exc, Http404): + return NotFound({'detail': _('Not found')}) + + elif isinstance(exc, PermissionDenied): + return Forbidden({'detail': _('Permission denied')}) + + # Note: Unhandled exceptions will raise a 500 error. + return None + + +class APIView(View): + # The following policies may be set at either globally, or per-view. + renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + parser_classes = api_settings.DEFAULT_PARSER_CLASSES + authentication_classes = api_settings.DEFAULT_AUTHENTICATION_CLASSES + throttle_classes = api_settings.DEFAULT_THROTTLE_CLASSES + permission_classes = api_settings.DEFAULT_PERMISSION_CLASSES + content_negotiation_class = api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS + + # Allow dependency injection of other settings to make testing easier. + settings = api_settings + + _throttle_instances = None + + @classmethod + def as_view(cls, **initkwargs): + """ + Store the original class on the view function. + + This allows us to discover information about the view when we do URL + reverse lookups. Used for breadcrumb generation. + """ + view = super(APIView, cls).as_view(**initkwargs) + view.cls = cls + return view + + @property + def allowed_methods(self): + """ + Wrap Django's private `_allowed_methods` interface in a public property. + """ + return self._allowed_methods() + + @property + def default_response_headers(self): + headers = { + 'Allow': ', '.join(self.allowed_methods), + } + if len(self.renderer_classes) > 1: + headers['Vary'] = 'Accept' + return headers + + def http_method_not_allowed(self, request, *args, **kwargs): + """ + If `request.method` does not correspond to a handler method, + determine what kind of exception to raise. + """ + raise exceptions.MethodNotAllowed(request.method) + + def permission_denied(self, request): + """ + If request is not permitted, determine what kind of exception to raise. + """ + if not request.successful_authenticator: + raise exceptions.NotAuthenticated() + raise exceptions.PermissionDenied() + + def throttled(self, request, wait): + """ + If request is throttled, determine what kind of exception to raise. + """ + raise exceptions.Throttled(wait) + + def get_authenticate_header(self, request): + """ + If a request is unauthenticated, determine the WWW-Authenticate + header to use for 401 responses, if any. + """ + authenticators = self.get_authenticators() + if authenticators: + return authenticators[0].authenticate_header(request) + + def get_parser_context(self, http_request): + """ + Returns a dict that is passed through to Parser.parse(), + as the `parser_context` keyword argument. + """ + # Note: Additionally `request` and `encoding` will also be added + # to the context by the Request object. + return { + 'view': self, + 'args': getattr(self, 'args', ()), + 'kwargs': getattr(self, 'kwargs', {}) + } + + def get_renderer_context(self): + """ + Returns a dict that is passed through to Renderer.render(), + as the `renderer_context` keyword argument. + """ + # Note: Additionally 'response' will also be added to the context, + # by the Response object. + return { + 'view': self, + 'args': getattr(self, 'args', ()), + 'kwargs': getattr(self, 'kwargs', {}), + 'request': getattr(self, 'request', None) + } + + def get_view_name(self): + """ + Return the view name, as used in OPTIONS responses and in the + browsable API. + """ + func = self.settings.VIEW_NAME_FUNCTION + return func(self.__class__, getattr(self, 'suffix', None)) + + def get_view_description(self, html=False): + """ + Return some descriptive text for the view, as used in OPTIONS responses + and in the browsable API. + """ + func = self.settings.VIEW_DESCRIPTION_FUNCTION + return func(self.__class__, html) + + # API policy instantiation methods + + def get_format_suffix(self, **kwargs): + """ + Determine if the request includes a '.json' style format suffix + """ + if self.settings.FORMAT_SUFFIX_KWARG: + return kwargs.get(self.settings.FORMAT_SUFFIX_KWARG) + + def get_renderers(self): + """ + Instantiates and returns the list of renderers that this view can use. + """ + return [renderer() for renderer in self.renderer_classes] + + def get_parsers(self): + """ + Instantiates and returns the list of parsers that this view can use. + """ + return [parser() for parser in self.parser_classes] + + def get_authenticators(self): + """ + Instantiates and returns the list of authenticators that this view can use. + """ + return [auth() for auth in self.authentication_classes] + + @as_tuple + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + """ + for permcls in self.permission_classes: + instance = permcls(request=self.request, + view=self) + yield instance + + def get_throttles(self): + """ + Instantiates and returns the list of throttles that this view uses. + """ + if self._throttle_instances is None: + self._throttle_instances = [throttle() for throttle in self.throttle_classes] + return self._throttle_instances + + def get_content_negotiator(self): + """ + Instantiate and return the content negotiation class to use. + """ + if not getattr(self, '_negotiator', None): + self._negotiator = self.content_negotiation_class() + return self._negotiator + + # API policy implementation methods + + def perform_content_negotiation(self, request, force=False): + """ + Determine which renderer and media type to use render the response. + """ + renderers = self.get_renderers() + conneg = self.get_content_negotiator() + + try: + return conneg.select_renderer(request, renderers, self.format_kwarg) + except Exception: + if force: + return (renderers[0], renderers[0].media_type) + raise + + def perform_authentication(self, request): + """ + Perform authentication on the incoming request. + + Note that if you override this and simply 'pass', then authentication + will instead be performed lazily, the first time either + `request.user` or `request.auth` is accessed. + """ + request.user + + def check_permissions(self, request, action:str=None, obj=None): + if action is None: + self.permission_denied(request) + + for permission in self.get_permissions(): + if not permission.check_permissions(action=action, obj=obj): + self.permission_denied(request) + + def check_object_permissions(self, request, obj): + self.check_permissions(request, None, obj) + + def check_throttles(self, request): + """ + Check if request should be throttled. + Raises an appropriate exception if the request is throttled. + """ + for throttle in self.get_throttles(): + if not throttle.allow_request(request, self): + self.throttled(request, throttle.wait()) + + def finalize_throttles(self, request, response): + """ + Check if request should be throttled. + Raises an appropriate exception if the request is throttled. + """ + for throttle in self.get_throttles(): + if throttle.has_to_finalize(request, response, self): + throttle.finalize(request, response, self) + + # Dispatch methods + + def initialize_request(self, request, *args, **kwargs): + """ + Returns the initial request object. + """ + parser_context = self.get_parser_context(request) + + return Request(request, + parsers=self.get_parsers(), + authenticators=self.get_authenticators(), + negotiator=self.get_content_negotiator(), + parser_context=parser_context) + + def initial(self, request, *args, **kwargs): + """ + Runs anything that needs to occur prior to calling the method handler. + """ + self.format_kwarg = self.get_format_suffix(**kwargs) + + # Ensure that the incoming request is permitted + self.perform_authentication(request) + self.check_throttles(request) + + # Perform content negotiation and store the accepted info on the request + neg = self.perform_content_negotiation(request) + request.accepted_renderer, request.accepted_media_type = neg + + def finalize_response(self, request, response, *args, **kwargs): + """ + Returns the final response object. + """ + # Make the error obvious if a proper response is not returned + assert isinstance(response, HttpResponseBase), ('Expected a `Response`, `HttpResponse` or ' + '`HttpStreamingResponse` to be returned from the view, ' + 'but received a `%s`' % type(response)) + + if isinstance(response, Response): + if not getattr(request, 'accepted_renderer', None): + neg = self.perform_content_negotiation(request, force=True) + request.accepted_renderer, request.accepted_media_type = neg + + response.accepted_renderer = request.accepted_renderer + response.accepted_media_type = request.accepted_media_type + response.renderer_context = self.get_renderer_context() + + for key, value in self.headers.items(): + response[key] = value + + self.finalize_throttles(request, response) + + return response + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + if isinstance(exc, (exceptions.NotAuthenticated, + exceptions.AuthenticationFailed)): + # WWW-Authenticate header for 401 responses, else coerce to 403 + auth_header = self.get_authenticate_header(self.request) + + if auth_header: + exc.auth_header = auth_header + else: + exc.status_code = status.HTTP_403_FORBIDDEN + + response = self.settings.EXCEPTION_HANDLER(exc) + + if response is None: + raise + + response.exception = True + return response + + # Note: session based authentication is explicitly CSRF validated, + # all other authentication is CSRF exempt. + @csrf_exempt + def dispatch(self, request, *args, **kwargs): + """ + `.dispatch()` is pretty much the same as Django's regular dispatch, + but with extra hooks for startup, finalize, and exception handling. + """ + self.args = args + self.kwargs = kwargs + request = self.initialize_request(request, *args, **kwargs) + self.request = request + self.headers = self.default_response_headers + + try: + self.initial(request, *args, **kwargs) + + # Get the appropriate handler method + if request.method.lower() in self.http_method_names: + handler = getattr(self, request.method.lower(), + self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response = handler(request, *args, **kwargs) + except Exception as exc: + response = self.handle_exception(exc) + + self.response = self.finalize_response(request, response, *args, **kwargs) + return self.response + + def options(self, request, *args, **kwargs): + """ + Handler method for HTTP 'OPTIONS' request. + We may as well implement this as Django will otherwise provide + a less useful default implementation. + """ + return Ok(self.metadata(request)) + + def metadata(self, request): + """ + Return a dictionary of metadata about the view. + Used to return responses for OPTIONS requests. + """ + # By default we can't provide any form-like information, however the + # generic views override this implementation and add additional + # information for POST and PUT methods, based on the serializer. + ret = OrderedDict() + ret['name'] = self.get_view_name() + ret['description'] = self.get_view_description() + ret['renders'] = [renderer.media_type for renderer in self.renderer_classes] + ret['parses'] = [parser.media_type for parser in self.parser_classes] + return ret + + +def api_server_error(request, *args, **kwargs): + if settings.DEBUG is False and request.headers.get('content-type', None) == "application/json": + return HttpResponse(json.dumps({"error": _("Server application error")}), + status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return server_error(request, *args, **kwargs) diff --git a/taiga/base/api/viewsets.py b/taiga/base/api/viewsets.py new file mode 100644 index 000000000..7d92759db --- /dev/null +++ b/taiga/base/api/viewsets.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from functools import update_wrapper +from django.utils.decorators import classonlymethod +from django.utils.translation import gettext as _ + +from . import views +from . import mixins +from . import generics + + +class ViewSetMixin(object): + """ + This is the magic. + + Overrides `.as_view()` so that it takes an `actions` keyword that performs + the binding of HTTP methods to actions on the Resource. + + For example, to create a concrete view binding the 'GET' and 'POST' methods + to the 'list' and 'create' actions... + + view = MyViewSet.as_view({'get': 'list', 'post': 'create'}) + """ + + @classonlymethod + def as_view(cls, actions=None, **initkwargs): + """ + Because of the way class based views create a closure around the + instantiated view, we need to totally reimplement `.as_view`, + and slightly modify the view function that is created and returned. + """ + # The suffix initkwarg is reserved for identifing the viewset type + # eg. 'List' or 'Instance'. + cls.suffix = None + + # sanitize keyword arguments + for key in initkwargs: + if key in cls.http_method_names: + raise TypeError("You tried to pass in the %s method name as a " + "keyword argument to %s(). Don't do that." + % (key, cls.__name__)) + if not hasattr(cls, key): + raise TypeError("%s() received an invalid keyword %r" + % (cls.__name__, key)) + + def view(request, *args, **kwargs): + self = cls(**initkwargs) + # We also store the mapping of request methods to actions, + # so that we can later set the action attribute. + # eg. `self.action = 'list'` on an incoming GET request. + self.action_map = actions + + # Bind methods to actions + # This is the bit that's different to a standard view + for method, action in actions.items(): + handler = getattr(self, action) + setattr(self, method, handler) + + # Patch this in as it's otherwise only present from 1.5 onwards + if hasattr(self, 'get') and not hasattr(self, 'head'): + self.head = self.get + + # And continue as usual + return self.dispatch(request, *args, **kwargs) + + # take name and docstring from class + update_wrapper(view, cls, updated=()) + + # and possible attributes set by decorators + # like csrf_exempt from dispatch + update_wrapper(view, cls.dispatch, assigned=()) + + # We need to set these on the view function, so that breadcrumb + # generation can pick out these bits of information from a + # resolved URL. + view.cls = cls + view.suffix = initkwargs.get('suffix', None) + return view + + def initialize_request(self, request, *args, **kargs): + """ + Set the `.action` attribute on the view, + depending on the request method. + """ + request = super(ViewSetMixin, self).initialize_request(request, *args, **kargs) + self.action = self.action_map.get(request.method.lower()) + return request + + def check_permissions(self, request, action:str=None, obj:object=None): + if action is None: + action = self.action + return super().check_permissions(request, action=action, obj=obj) + + +class NestedViewSetMixin(object): + def get_queryset(self): + return self._filter_queryset_by_parents_lookups(super().get_queryset()) + + def _filter_queryset_by_parents_lookups(self, queryset): + parents_query_dict = self._get_parents_query_dict() + if parents_query_dict: + return queryset.filter(**parents_query_dict) + else: + return queryset + + def _get_parents_query_dict(self): + result = {} + for kwarg_name in self.kwargs: + query_value = self.kwargs.get(kwarg_name) + result[kwarg_name] = query_value + return result + + +class ViewSet(ViewSetMixin, views.APIView): + """ + The base ViewSet class does not provide any actions by default. + """ + pass + + +class GenericViewSet(ViewSetMixin, generics.GenericAPIView): + """ + The GenericViewSet class does not provide any actions by default, + but does include the base set of generic view behavior, such as + the `get_object` and `get_queryset` methods. + """ + pass + + +class ReadOnlyListViewSet(GenericViewSet): + """ + A viewset that provides default `list()` action. + """ + pass + + +class ReadOnlyModelViewSet(mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet): + """ + A viewset that provides default `list()` and `retrieve()` actions. + """ + pass + + +class ModelViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + GenericViewSet): + """ + A viewset that provides default `create()`, `retrieve()`, `update()`, + `partial_update()`, `destroy()` and `list()` actions. + """ + pass + + +class ModelCrudViewSet(ModelViewSet): + pass + + +class ModelListViewSet(mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet): + pass + + +class ModelUpdateRetrieveViewSet(mixins.UpdateModelMixin, + mixins.RetrieveModelMixin, + GenericViewSet): + pass + + +class ModelRetrieveViewSet(mixins.RetrieveModelMixin, + GenericViewSet): + pass diff --git a/taiga/base/apps.py b/taiga/base/apps.py new file mode 100644 index 000000000..dcc4f9607 --- /dev/null +++ b/taiga/base/apps.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig + + +class BaseAppConfig(AppConfig): + name = "taiga.base" + verbose_name = "Base App Config" + + def ready(self): + from .signals.thumbnails import connect_thumbnail_signals + from .signals.cleanup_files import connect_cleanup_files_signals + + connect_thumbnail_signals() + connect_cleanup_files_signals() diff --git a/taiga/base/connectors/__init__.py b/taiga/base/connectors/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/connectors/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/connectors/exceptions.py b/taiga/base/connectors/exceptions.py new file mode 100644 index 000000000..2b9b8004c --- /dev/null +++ b/taiga/base/connectors/exceptions.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.exceptions import BaseException + +from django.utils.translation import gettext_lazy as _ + + +class ConnectorBaseException(BaseException): + status_code = 400 + default_detail = _("Connection error.") diff --git a/taiga/base/db/__init__.py b/taiga/base/db/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/db/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/db/models/__init__.py b/taiga/base/db/models/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/db/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/db/models/fields/__init__.py b/taiga/base/db/models/fields/__init__.py new file mode 100644 index 000000000..20169f6ac --- /dev/null +++ b/taiga/base/db/models/fields/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from .json import JSONField diff --git a/taiga/base/db/models/fields/json.py b/taiga/base/db/models/fields/json.py new file mode 100644 index 000000000..ba84f1bc2 --- /dev/null +++ b/taiga/base/db/models/fields/json.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import JSONField as DjangoJSONField + + +__all__ = ["JSONField"] + + +class JSONField(DjangoJSONField): + def __init__(self, verbose_name=None, name=None, encoder=DjangoJSONEncoder, **kwargs): + super().__init__(verbose_name, name, encoder, **kwargs) diff --git a/taiga/base/decorators.py b/taiga/base/decorators.py new file mode 100644 index 000000000..d91d8ef44 --- /dev/null +++ b/taiga/base/decorators.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django_pglocks import advisory_lock + + +def detail_route(methods=['get'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for detail requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.detail = True + func.permission_classes = kwargs.get('permission_classes', []) + func.kwargs = kwargs + return func + return decorator + + +def list_route(methods=['get'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for list requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.detail = False + func.permission_classes = kwargs.get('permission_classes', []) + func.kwargs = kwargs + return func + return decorator + + +def model_pk_lock(func): + """ + This decorator is designed to be used in ModelViewsets methods to lock them based + on the model and the id of the selected object. + """ + def decorator(self, *args, **kwargs): + from taiga.base.utils.db import get_typename_for_model_class + pk = self.kwargs.get(self.pk_url_kwarg, None) + tn = get_typename_for_model_class(self.get_queryset().model) + key = "{0}:{1}".format(tn, pk) + + with advisory_lock(key): + return func(self, *args, **kwargs) + + return decorator diff --git a/taiga/base/exceptions.py b/taiga/base/exceptions.py new file mode 100644 index 000000000..8bea4ddef --- /dev/null +++ b/taiga/base/exceptions.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +""" +Handled exceptions raised by REST framework. + +In addition Django's built in 403 and 404 exceptions are handled. +(`django.http.Http404` and `django.core.exceptions.PermissionDenied`) +""" + +from django.core.exceptions import PermissionDenied as DjangoPermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils.encoding import force_str +from django.utils.translation import gettext_lazy as _ +from django.http import Http404 + +from . import response +from . import status + +import math + + +class APIException(Exception): + """ + Base class for REST framework exceptions. + Subclasses should provide `.status_code` and `.default_detail` properties. + """ + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = "" + + def __init__(self, detail=None): + self.detail = detail or self.default_detail + + +class ParseError(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _("Malformed request.") + + +class AuthenticationFailed(APIException): + status_code = status.HTTP_401_UNAUTHORIZED + default_detail = _("Incorrect authentication credentials.") + + +class NotAuthenticated(APIException): + status_code = status.HTTP_401_UNAUTHORIZED + default_detail = _("Authentication credentials were not provided.") + + +class PermissionDenied(APIException): + status_code = status.HTTP_403_FORBIDDEN + default_detail = _("You do not have permission to perform this action.") + + +class MethodNotAllowed(APIException): + status_code = status.HTTP_405_METHOD_NOT_ALLOWED + default_detail = _("Method '%s' not allowed.") + + def __init__(self, method, detail=None): + self.detail = (detail or self.default_detail) % method + + +class NotAcceptable(APIException): + status_code = status.HTTP_406_NOT_ACCEPTABLE + default_detail = _("Could not satisfy the request's Accept header") + + def __init__(self, detail=None, available_renderers=None): + self.detail = detail or self.default_detail + self.available_renderers = available_renderers + + +class UnsupportedMediaType(APIException): + status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + default_detail = _("Unsupported media type '%s' in request.") + + def __init__(self, media_type, detail=None): + self.detail = (detail or self.default_detail) % media_type + + +class Throttled(APIException): + status_code = status.HTTP_429_TOO_MANY_REQUESTS + default_detail = _("Request was throttled.") + extra_detail = _("Expected available in %d second%s.") + + def __init__(self, wait=None, detail=None): + if wait is None: + self.detail = detail or self.default_detail + self.wait = None + else: + format = "%s%s" % ((detail or self.default_detail), self.extra_detail) + self.detail = format % (wait, wait != 1 and "s" or "") + self.wait = math.ceil(wait) + + +class BaseException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _("Unexpected error") + + def __init__(self, detail=None): + self.detail = detail or self.default_detail + + +class NotFound(BaseException, Http404): + """ + Exception used for not found objects. + """ + + status_code = status.HTTP_404_NOT_FOUND + default_detail = _("Not found.") + + +class NotSupported(BaseException): + status_code = status.HTTP_405_METHOD_NOT_ALLOWED + default_detail = _("Method not supported for this endpoint.") + + +class BadRequest(BaseException): + """ + Exception used on bad arguments detected + on api view. + """ + default_detail = _("Wrong arguments.") + + +class WrongArguments(BadRequest): + """ + Exception used on bad arguments detected + on service. This is same as `BadRequest`. + """ + default_detail = _("Wrong arguments.") + + +class RequestValidationError(BadRequest): + default_detail = _("Data validation error") + + +class PermissionDenied(PermissionDenied): + """ + Compatibility subclass of restframework `PermissionDenied` + exception. + """ + pass + + +class IntegrityError(BadRequest): + default_detail = _("Integrity Error for wrong or invalid arguments") + + +class PreconditionError(BadRequest): + """ + Error raised on precondition method on viewset. + """ + default_detail = _("Precondition error") + + +class NotAuthenticated(NotAuthenticated): + """ + Compatibility subclass of restframework `NotAuthenticated` + exception. + """ + pass + + +class Blocked(APIException): + """ + Exception used on blocked projects + """ + status_code = status.HTTP_451_BLOCKED + default_detail = _("Blocked element") + + +class NotEnoughSlotsForProject(BaseException): + """ + Exception used on import/edition/creation project errors where the user + hasn't slots enough + """ + default_detail = _("No room left for more projects.") + + def __init__(self, is_private, total_memberships, detail=None): + self.detail = detail or self.default_detail + self.project_data = { + "is_private": is_private, + "total_memberships": total_memberships + } + + +def format_exception(exc): + if isinstance(exc.detail, (dict, list, tuple,)): + detail = exc.detail + else: + class_name = exc.__class__.__name__ + class_module = exc.__class__.__module__ + detail = { + "_error_message": force_str(exc.detail), + "_error_type": "{0}.{1}".format(class_module, class_name) + } + + return detail + + +def exception_handler(exc): + """ + Returns the response that should be used for any given exception. + + By default we handle the REST framework `APIException`, and also + Django's builtin `Http404` and `PermissionDenied` exceptions. + + Any unhandled exceptions may return `None`, which will cause a 500 error + to be raised. + """ + + if isinstance(exc, APIException): + res = response.Response(format_exception(exc), status=exc.status_code) + + if getattr(exc, "auth_header", None): + res["WWW-Authenticate"] = exc.auth_header + if getattr(exc, "wait", None): + res["X-Throttle-Wait-Seconds"] = "%d" % exc.wait + if getattr(exc, "project_data", None): + res["Taiga-Info-Project-Memberships"] = exc.project_data["total_memberships"] + res["Taiga-Info-Project-Is-Private"] = exc.project_data["is_private"] + + return res + + elif isinstance(exc, Http404): + return response.NotFound({'_error_message': str(exc)}) + + elif isinstance(exc, DjangoPermissionDenied): + return response.Forbidden({"_error_message": str(exc)}) + + # Note: Unhandled exceptions will raise a 500 error. + return None + + +ValidationError = DjangoValidationError diff --git a/taiga/base/fields.py b/taiga/base/fields.py new file mode 100644 index 000000000..d6c1ce565 --- /dev/null +++ b/taiga/base/fields.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.forms import widgets +from django.utils.translation import gettext as _ +from taiga.base.api import serializers, ISO_8601 +from taiga.base.api.settings import api_settings + +import serpy + + +#################################################################### +# DRF Serializer fields (OLD) +#################################################################### +# NOTE: This should be in other place, for example taiga.base.api.serializers + + +class JSONField(serializers.WritableField): + """ + Json objects serializer. + """ + widget = widgets.Textarea + + def to_native(self, obj): + return obj + + def from_native(self, data): + return data + + +class PgArrayField(serializers.WritableField): + """ + PgArray objects serializer. + """ + widget = widgets.Textarea + + def to_native(self, obj): + return obj + + def from_native(self, data): + return data + + +class PickledObjectField(serializers.WritableField): + """ + PickledObjectField objects serializer. + """ + widget = widgets.Textarea + + def to_native(self, obj): + return obj + + def from_native(self, data): + return data + + +class WatchersField(serializers.WritableField): + def to_native(self, obj): + return obj + + def from_native(self, data): + return data + + +class ListField(serializers.WritableField): + """ + A field whose values are lists of items described by the given child. The child can + be another field type (e.g., CharField) or a serializer. However, for serializers, you should + instead just use it with the `many=True` option. + """ + + default_error_messages = { + 'invalid_type': _('%(value)s is not a list'), + } + empty = [] + + def __init__(self, child=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.child = child + + def initialize(self, parent, field_name): + super().initialize(parent, field_name) + if self.child: + self.child.initialize(parent, field_name) + + def to_native(self, obj): + if self.child and obj: + return [self.child.to_native(item) for item in obj] + return obj + + def from_native(self, data): + self.validate_is_list(data) + if self.child and data: + return [self.child.from_native(item_data) for item_data in data] + return data + + def validate(self, value): + super().validate(value) + + self.validate_is_list(value) + + if self.child: + errors = {} + for index, item in enumerate(value): + try: + self.child.validate(item) + except ValidationError as e: + errors[index] = e.messages + + if errors: + raise NestedValidationError(errors) + + def run_validators(self, value): + super().run_validators(value) + + if self.child: + errors = {} + for index, item in enumerate(value): + try: + self.child.run_validators(item) + except ValidationError as e: + errors[index] = e.messages + + if errors: + raise NestedValidationError(errors) + + def validate_is_list(self, value): + if value is not None and not isinstance(value, list): + raise ValidationError(self.error_messages['invalid_type'], + code='invalid_type', + params={'value': value}) + + +#################################################################### +# Serpy fields (NEW) +#################################################################### + +class Field(serpy.Field): + pass + + +class MethodField(serpy.MethodField): + pass + + +class I18NField(Field): + def to_value(self, value): + ret = super(I18NField, self).to_value(value) + return _(ret) + + +class I18NJSONField(Field): + """ + Json objects serializer. + """ + def __init__(self, i18n_fields=(), *args, **kwargs): + super(I18NJSONField, self).__init__(*args, **kwargs) + self.i18n_fields = i18n_fields + + def translate_values(self, d): + i18n_d = {} + if d is None: + return d + + for key, value in d.items(): + if isinstance(value, dict): + i18n_d[key] = self.translate_values(value) + + if key in self.i18n_fields: + if isinstance(value, list): + i18n_d[key] = [e is not None and _(str(e)) or e for e in value] + if isinstance(value, str): + i18n_d[key] = value is not None and _(value) or value + else: + i18n_d[key] = value + + return i18n_d + + def to_native(self, obj): + i18n_obj = self.translate_values(obj) + return i18n_obj + + +class FileField(Field): + def to_value(self, value): + if value: + return value.name + return None + + +class DateTimeField(Field): + format = api_settings.DATETIME_FORMAT + + def to_value(self, value): + if value is None or self.format is None: + return value + + if self.format.lower() == ISO_8601: + ret = value.isoformat() + if ret.endswith("+00:00"): + ret = ret[:-6] + "Z" + return ret + return value.strftime(self.format) diff --git a/taiga/base/filters.py b/taiga/base/filters.py new file mode 100644 index 000000000..6b65da4b1 --- /dev/null +++ b/taiga/base/filters.py @@ -0,0 +1,669 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import logging + +from dateutil.parser import parse as parse_date + +from django.apps import apps +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from django.utils.translation import gettext as _ + +from taiga.base import exceptions as exc +from taiga.base.api.utils import get_object_or_error +from taiga.base.utils.db import to_tsquery + +logger = logging.getLogger(__name__) + + +def get_filter_expression_can_view_projects(user, project_id=None): + # Filter by user permissions + if user.is_authenticated and user.is_superuser: + return Q() + elif user.is_authenticated: + # authenticated user & project member + membership_model = apps.get_model("projects", "Membership") + memberships_qs = membership_model.objects.filter(user=user) + if project_id: + memberships_qs = memberships_qs.filter(project_id=project_id) + memberships_qs = memberships_qs.filter( + Q(role__permissions__contains=['view_project']) | + Q(is_admin=True)) + + projects_list = [membership.project_id for membership in + memberships_qs] + + return (Q(id__in=projects_list) | + Q(public_permissions__contains=["view_project"])) + else: + # external users / anonymous + return Q(anon_permissions__contains=["view_project"]) + + +##################################################################### +# Base and Mixins +##################################################################### + +class BaseFilterBackend(object): + """ + A base class from which all filter backend classes should inherit. + """ + + def filter_queryset(self, request, queryset, view): + """ + Return a filtered queryset. + """ + raise NotImplementedError(".filter_queryset() must be overridden.") + + +class QueryParamsFilterMixin(BaseFilterBackend): + _special_values_dict = { + 'true': True, + 'false': False, + 'null': None, + } + + def filter_queryset(self, request, queryset, view): + query_params = {} + + if not hasattr(view, "filter_fields"): + return queryset + + for field in view.filter_fields: + if isinstance(field, (tuple, list)): + param_name, field_name = field + else: + param_name, field_name = field, field + + if param_name in request.QUERY_PARAMS: + field_data = request.QUERY_PARAMS[param_name] + if field_data in self._special_values_dict: + query_params[field_name] = self._special_values_dict[field_data] + else: + query_params[field_name] = field_data + + if query_params: + try: + queryset = queryset.filter(**query_params) + except ValueError: + raise exc.BadRequest(_("Error in filter params types.")) + + return queryset + + +class OrderByFilterMixin(QueryParamsFilterMixin): + order_by_query_param = "order_by" + + def filter_queryset(self, request, queryset, view): + queryset = super().filter_queryset(request, queryset, view) + order_by_fields = getattr(view, "order_by_fields", None) + + raw_fieldname = request.QUERY_PARAMS.get(self.order_by_query_param, None) + if not raw_fieldname or not order_by_fields: + return queryset + + if raw_fieldname.startswith("-"): + field_name = raw_fieldname[1:] + else: + field_name = raw_fieldname + + if field_name not in order_by_fields: + return queryset + + if raw_fieldname in ["owner", "-owner", "assigned_to", "-assigned_to"]: + raw_fieldname = "{}__full_name".format(raw_fieldname) + + # We need to add a default order if raw_fieldname gives rows with the same value + return super().filter_queryset(request, queryset.order_by(raw_fieldname, "-id"), view) + + +class FilterBackend(OrderByFilterMixin): + """ + Default filter backend. + """ + pass + + +##################################################################### +# Permissions filters +##################################################################### + +class PermissionBasedFilterBackend(FilterBackend): + permission = None + project_query_param = "project" + + def filter_queryset(self, request, queryset, view): + project_id = None + if (hasattr(view, "filter_fields") and "project" in view.filter_fields and + "project" in request.QUERY_PARAMS): + try: + project_id = int(request.QUERY_PARAMS["project"]) + except: + logger.error("Filtering project diferent value than an integer: {}".format( + request.QUERY_PARAMS["project"] + )) + raise exc.BadRequest(_("'project' must be an integer value.")) + + qs = queryset + + if request.user.is_authenticated and request.user.is_superuser: + qs = qs + elif request.user.is_authenticated: + membership_model = apps.get_model('projects', 'Membership') + memberships_qs = membership_model.objects.filter(user=request.user) + if project_id: + memberships_qs = memberships_qs.filter(project_id=project_id) + memberships_qs = memberships_qs.filter( + Q(role__permissions__contains=[self.permission]) | + Q(is_admin=True)) + + projects_list = [membership.project_id for membership in memberships_qs] + + qs = qs.filter(Q(**{f"{self.project_query_param}_id__in": projects_list}) | + Q(**{f"{self.project_query_param}__public_permissions__contains": [self.permission]})) + else: + qs = qs.filter(**{f"{self.project_query_param}__anon_permissions__contains": [self.permission]}) + + return super().filter_queryset(request, qs, view) + + +def custom_filter_class(base_class, **kwargs): + """ + This function is useful to create custom filter classes based on other classes. + + For exmaple: + + class CanViewProjectFilterBackend(PermissionBasedFilterBackend): + permission = "view_project" + + is the same as: + + custom_filter_class(PermissionBasedFilterBackend, {permission: "view_project"}) + """ + return type(f"{base_class.__name__}_customized", (base_class,), kwargs) + + +class CanViewProjectFilterBackend(PermissionBasedFilterBackend): + permission = "view_project" + + +class CanViewEpicsFilterBackend(PermissionBasedFilterBackend): + permission = "view_epics" + + +class CanViewUsFilterBackend(PermissionBasedFilterBackend): + permission = "view_us" + + +class CanViewIssuesFilterBackend(PermissionBasedFilterBackend): + permission = "view_issues" + + +class CanViewTasksFilterBackend(PermissionBasedFilterBackend): + permission = "view_tasks" + + +class CanViewWikiPagesFilterBackend(PermissionBasedFilterBackend): + permission = "view_wiki_pages" + + +class CanViewWikiLinksFilterBackend(PermissionBasedFilterBackend): + permission = "view_wiki_links" + + +class CanViewMilestonesFilterBackend(PermissionBasedFilterBackend): + permission = "view_milestones" + + +##################################################################### +# Attachments filters +##################################################################### + +class PermissionBasedAttachmentFilterBackend(PermissionBasedFilterBackend): + permission = None + + def filter_queryset(self, request, queryset, view): + qs = super().filter_queryset(request, queryset, view) + + ct = view.get_content_type() + return qs.filter(content_type=ct) + + +class CanViewEpicAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend): + permission = "view_epics" + + +class CanViewUserStoryAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend): + permission = "view_us" + + +class CanViewTaskAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend): + permission = "view_tasks" + + +class CanViewIssueAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend): + permission = "view_issues" + + +class CanViewWikiAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend): + permission = "view_wiki_pages" + + +##################################################################### +# User filters +##################################################################### + +class MembersFilterBackend(PermissionBasedFilterBackend): + permission = "view_project" + + def filter_queryset(self, request, queryset, view): + project_id = None + project = None + qs = queryset.filter(is_active=True) + if "project" in request.QUERY_PARAMS: + try: + project_id = int(request.QUERY_PARAMS["project"]) + except: + logger.error("Filtering project diferent value than an integer: {}".format( + request.QUERY_PARAMS["project"])) + raise exc.BadRequest(_("'project' must be an integer value.")) + + if project_id: + Project = apps.get_model('projects', 'Project') + project = get_object_or_error(Project, request.user, pk=project_id) + + if request.user.is_authenticated and request.user.is_superuser: + qs = qs + elif request.user.is_authenticated: + Membership = apps.get_model('projects', 'Membership') + memberships_qs = Membership.objects.filter(user=request.user) + if project_id: + memberships_qs = memberships_qs.filter(project_id=project_id) + memberships_qs = memberships_qs.filter( + Q(role__permissions__contains=[self.permission]) | + Q(is_admin=True)) + + projects_list = [membership.project_id for membership in memberships_qs] + + if project: + is_member = project.id in projects_list + has_project_public_view_permission = "view_project" in project.public_permissions + if not is_member and not has_project_public_view_permission: + qs = qs.none() + + q = Q(memberships__project_id__in=projects_list) | Q(id=request.user.id) + + # If there is no selected project we want access to users from public projects + if not project: + q = q | Q(memberships__project__public_permissions__contains=[self.permission]) + + qs = qs.filter(q) + + else: + if project and "view_project" not in project.anon_permissions: + qs = qs.none() + + qs = qs.filter(memberships__project__anon_permissions__contains=[self.permission]) + + return qs.distinct() + + +##################################################################### +# Webhooks filters +##################################################################### + +class BaseIsProjectAdminFilterBackend(object): + def get_project_ids(self, request, view): + project_id = None + if hasattr(view, "filter_fields") and "project" in view.filter_fields: + project_id = request.QUERY_PARAMS.get("project", None) + + if request.user.is_authenticated and request.user.is_superuser: + return None + + if not request.user.is_authenticated: + return [] + + membership_model = apps.get_model('projects', 'Membership') + memberships_qs = membership_model.objects.filter(user=request.user, is_admin=True) + if project_id: + memberships_qs = memberships_qs.filter(project_id=project_id) + + projects_list = [membership.project_id for membership in memberships_qs] + + return projects_list + + +class IsProjectAdminFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend): + def filter_queryset(self, request, queryset, view): + project_ids = self.get_project_ids(request, view) + if project_ids is None: + queryset = queryset + elif project_ids == []: + queryset = queryset.none() + else: + queryset = queryset.filter(project_id__in=project_ids) + + return super().filter_queryset(request, queryset, view) + + +class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend): + def filter_queryset(self, request, queryset, view): + project_ids = self.get_project_ids(request, view) + if project_ids is None: + queryset = queryset + elif project_ids == []: + queryset = queryset.none() + else: + queryset = queryset.filter(webhook__project_id__in=project_ids) + + return super().filter_queryset(request, queryset, view) + + +##################################################################### +# Generic Attributes filters (for User Stories, Tasks and Issues) +##################################################################### + +class BaseRelatedFieldsFilter(FilterBackend): + filter_name = None + param_name = None + exclude_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 = exclude_param_name + + def _prepare_filter_data(self, query_param_value): + def _transform_value(value): + try: + return int(value) + except: + if value in self._special_values_dict: + return self._special_values_dict[value] + raise exc.BadRequest() + + values = set([x.strip() for x in query_param_value.split(",")]) + values = map(_transform_value, values) + return list(values) + + 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 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): + 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 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, exclude_param_name=None): + if filter_name: + self.filter_name = filter_name + + 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, tags): + queries = [Q(tags__contains=[tag]) for tag in tags] + query = queries.pop() + for item in queries: + query |= item + + return Q(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): + 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) + + +class WatchersFilter(FilterBackend): + filter_name = 'watchers' + + def __init__(self, filter_name=None): + if filter_name: + self.filter_name = filter_name + + def _get_watchers_queryparams(self, params): + watchers = params.get(self.filter_name, None) + if watchers: + return watchers.split(",") + + return None + + def filter_queryset(self, request, queryset, view): + query_watchers = self._get_watchers_queryparams(request.QUERY_PARAMS) + if query_watchers: + WatchedModel = apps.get_model("notifications", "Watched") + watched_type = ContentType.objects.get_for_model(queryset.model) + + try: + watched_ids = (WatchedModel.objects.filter(content_type=watched_type, + user__id__in=query_watchers) + .values_list("object_id", flat=True)) + queryset = queryset.filter(id__in=watched_ids) + except ValueError: + raise exc.BadRequest(_("Error in filter params types.")) + + return super().filter_queryset(request, queryset, view) + + +class BaseCompareFilter(FilterBackend): + operators = ["", "lt", "gt", "lte", "gte"] + + def __init__(self, filter_name_base=None, operators=None): + if filter_name_base: + self.filter_name_base = filter_name_base + + def _get_filter_names(self): + return [ + self._get_filter_name(operator) + for operator in self.operators + ] + + def _get_filter_name(self, operator): + if operator and len(operator) > 0: + return "{base}__{operator}".format( + base=self.filter_name_base, operator=operator + ) + else: + return self.filter_name_base + + def _get_constraints(self, params): + constraints = {} + for filter_name in self._get_filter_names(): + raw_value = params.get(filter_name, None) + if raw_value is not None: + constraints[filter_name] = self._get_value(raw_value) + return constraints + + def _get_value(self, raw_value): + return raw_value + + def filter_queryset(self, request, queryset, view): + constraints = self._get_constraints(request.QUERY_PARAMS) + + if len(constraints) > 0: + queryset = queryset.filter(**constraints) + + return super().filter_queryset(request, queryset, view) + + +class BaseDateFilter(BaseCompareFilter): + def _get_value(self, raw_value): + return parse_date(raw_value) + + +class CreatedDateFilter(BaseDateFilter): + filter_name_base = "created_date" + + +class ModifiedDateFilter(BaseDateFilter): + filter_name_base = "modified_date" + + +class FinishedDateFilter(BaseDateFilter): + filter_name_base = "finished_date" + + +class FinishDateFilter(BaseDateFilter): + filter_name_base = "finish_date" + + +class EstimatedStartFilter(BaseDateFilter): + filter_name_base = "estimated_start" + + +class EstimatedFinishFilter(BaseDateFilter): + filter_name_base = "estimated_finish" + + +class MilestoneEstimatedStartFilter(BaseDateFilter): + filter_name_base = "milestone__estimated_start" + + +class MilestoneEstimatedFinishFilter(BaseDateFilter): + filter_name_base = "milestone__estimated_finish" + + +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') + + 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) + +##################################################################### +# Text search filters +##################################################################### + +class QFilter(FilterBackend): + def filter_queryset(self, request, queryset, view): + q = request.QUERY_PARAMS.get('q', None) + if q: + table = queryset.model._meta.db_table + where_clause = (""" + to_tsvector('simple', + coalesce({table}.subject, '') || ' ' || + coalesce(array_to_string({table}.tags, ' '), '') || ' ' || + coalesce({table}.ref) || ' ' || + coalesce({table}.description, '')) @@ to_tsquery('simple', %s) + """.format(table=table)) + + queryset = queryset.extra(where=[where_clause], params=[to_tsquery(q)]) + + return queryset + + diff --git a/taiga/base/formats/__init__.py b/taiga/base/formats/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/formats/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/formats/en/__init__.py b/taiga/base/formats/en/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/formats/en/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/formats/en/formats.py b/taiga/base/formats/en/formats.py new file mode 100644 index 000000000..0842adeb3 --- /dev/null +++ b/taiga/base/formats/en/formats.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +DATE_FORMAT = "d/m/Y" +SHORT_DATE_FORMAT = "d/m/Y" + +DATE_INPUT_FORMATS = ( + '%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%b %d %Y', + '%b %d, %Y', '%d %b %Y', '%d %b, %Y', '%B %d %Y', + '%B %d, %Y', '%d %B %Y', '%d %B, %Y' +) + +DATETIME_INPUT_FORMATS = ( + '%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d', + '%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M', '%m/%d/%Y', + '%m/%d/%y %H:%M:%S', '%m/%d/%y %H:%M', '%m/%d/%y' +) +DECIMAL_SEPARATOR = '.' diff --git a/taiga/base/formats/es/__init__.py b/taiga/base/formats/es/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/formats/es/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/formats/es/formats.py b/taiga/base/formats/es/formats.py new file mode 100644 index 000000000..3c2f342c1 --- /dev/null +++ b/taiga/base/formats/es/formats.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +DATE_FORMAT = "d/m/Y" +SHORT_DATE_FORMAT = "d/m/Y" +DATE_INPUT_FORMATS = ( + '%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%b %d %Y', + '%b %d, %Y', '%d %b %Y', '%d %b, %Y', '%B %d %Y', + '%B %d, %Y', '%d %B %Y', '%d %B, %Y' +) +DATETIME_INPUT_FORMATS = ( + '%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d', + '%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M', '%m/%d/%Y', + '%m/%d/%y %H:%M:%S', '%m/%d/%y %H:%M', '%m/%d/%y' +) +DECIMAL_SEPARATOR = '.' diff --git a/taiga/base/mails.py b/taiga/base/mails.py new file mode 100644 index 000000000..d15624806 --- /dev/null +++ b/taiga/base/mails.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings + +from djmail import template_mail +import premailer + +import logging + + +# Hide CSS warnings messages if debug mode is disable +if not getattr(settings, "DEBUG", False): + premailer.premailer.cssutils.log.setLevel(logging.CRITICAL) + + +class InlineCSSTemplateMail(template_mail.TemplateMail): + def _render_message_body_as_html(self, context): + html = super()._render_message_body_as_html(context) + + # Transform CSS into line style attributes + return premailer.transform(html) + + +class MagicMailBuilder(template_mail.MagicMailBuilder): + pass + + +mail_builder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) diff --git a/taiga/base/management/__init__.py b/taiga/base/management/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/management/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/management/commands/__init__.py b/taiga/base/management/commands/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/management/commands/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/management/commands/test_emails.py b/taiga/base/management/commands/test_emails.py new file mode 100644 index 000000000..c314cb151 --- /dev/null +++ b/taiga/base/management/commands/test_emails.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime + +from django.apps import apps +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.utils import timezone + +from taiga.base.mails import InlineCSSTemplateMail +from taiga.base.mails import mail_builder + +from taiga.projects.models import Project, Membership +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.services import get_history_queryset_by_model_instance + +from taiga.users.services import get_user_photo_url +from taiga.front.templatetags.functions import resolve as resolve_front_url + + +class Command(BaseCommand): + help = 'Send an example of all emails' + + def add_arguments(self, parser): + parser.add_argument('--locale', '-l', + default=None, + dest='locale', + help='Send emails in an specific language.') + parser.add_argument('email', + help='Email address to send sample emails to.') + + + def handle(self, *args, **options): + locale = options.get('locale') + email_address = options.get('email') + + # Register email + context = {"lang": locale, + "user": get_user_model().objects.all().order_by("?").first(), + "cancel_token": "cancel-token"} + + email = mail_builder.registered_user(email_address, context) + email.send() + + # Membership invitation + membership = Membership.objects.order_by("?").filter(user__isnull=True).first() + membership.invited_by = get_user_model().objects.all().order_by("?").first() + membership.invitation_extra_text = "Text example, Text example,\nText example,\n\nText example" + + context = {"lang": locale, "membership": membership} + email = mail_builder.membership_invitation(email_address, context) + email.send() + + # Membership notification + context = {"lang": locale, + "membership": Membership.objects.order_by("?").filter(user__isnull=False).first()} + email = mail_builder.membership_notification(email_address, context) + email.send() + + # Feedback + context = { + "lang": locale, + "feedback_entry": { + "full_name": "Test full name", + "email": "test@email.com", + "comment": "Test comment", + }, + "extra": { + "key1": "value1", + "key2": "value2", + }, + } + email = mail_builder.feedback_notification(email_address, context) + email.send() + + # Password recovery + context = {"lang": locale, "user": get_user_model().objects.all().order_by("?").first()} + email = mail_builder.password_recovery(email_address, context) + email.send() + + # Change email + context = {"lang": locale, "user": get_user_model().objects.all().order_by("?").first()} + email = mail_builder.change_email(email_address, context) + email.send() + + # Export/Import emails + context = { + "lang": locale, + "user": get_user_model().objects.all().order_by("?").first(), + "project": Project.objects.all().order_by("?").first(), + "error_subject": "Error generating project dump", + "error_message": "Error generating project dump", + } + email = mail_builder.export_error(email_address, context) + email.send() + context = { + "lang": locale, + "user": get_user_model().objects.all().order_by("?").first(), + "error_subject": "Error importing project dump", + "error_message": "Error importing project dump", + } + email = mail_builder.import_error(email_address, context) + email.send() + + deletion_date = timezone.now() + datetime.timedelta(seconds=60*60*24) + context = { + "lang": locale, + "url": "http://dummyurl.com", + "user": get_user_model().objects.all().order_by("?").first(), + "project": Project.objects.all().order_by("?").first(), + "deletion_date": deletion_date, + } + email = mail_builder.dump_project(email_address, context) + email.send() + + context = { + "lang": locale, + "user": get_user_model().objects.all().order_by("?").first(), + "project": Project.objects.all().order_by("?").first(), + } + email = mail_builder.load_dump(email_address, context) + email.send() + + # Notification emails + notification_emails = [ + ("issues.Issue", "issues/issue-change"), + ("issues.Issue", "issues/issue-create"), + ("issues.Issue", "issues/issue-delete"), + ("tasks.Task", "tasks/task-change"), + ("tasks.Task", "tasks/task-create"), + ("tasks.Task", "tasks/task-delete"), + ("userstories.UserStory", "userstories/userstory-change"), + ("userstories.UserStory", "userstories/userstory-create"), + ("userstories.UserStory", "userstories/userstory-delete"), + ("milestones.Milestone", "milestones/milestone-change"), + ("milestones.Milestone", "milestones/milestone-create"), + ("milestones.Milestone", "milestones/milestone-delete"), + ("wiki.WikiPage", "wiki/wikipage-change"), + ("wiki.WikiPage", "wiki/wikipage-create"), + ("wiki.WikiPage", "wiki/wikipage-delete"), + ] + + context = { + "lang": locale, + "project": Project.objects.all().order_by("?").first(), + "changer": get_user_model().objects.all().order_by("?").first(), + "history_entries": HistoryEntry.objects.all().order_by("?")[0:5], + "user": get_user_model().objects.all().order_by("?").first(), + } + + for notification_email in notification_emails: + model = apps.get_model(*notification_email[0].split(".")) + snapshot = { + "subject": "Tests subject", + "ref": 123123, + "name": "Tests name", + "slug": "test-slug" + } + queryset = model.objects.all().order_by("?") + for obj in queryset: + end = False + entries = get_history_queryset_by_model_instance(obj).filter(is_snapshot=True).order_by("?") + + for entry in entries: + if entry.snapshot: + snapshot = entry.snapshot + end = True + break + if end: + break + context["snapshot"] = snapshot + + cls = type("InlineCSSTemplateMail", (InlineCSSTemplateMail,), {"name": notification_email[1]}) + email = cls() + email.send(email_address, context) + + # Transfer Emails + context = { + "project": Project.objects.all().order_by("?").first(), + "requester": get_user_model().objects.all().order_by("?").first(), + } + email = mail_builder.transfer_request(email_address, context) + email.send() + + context = { + "project": Project.objects.all().order_by("?").first(), + "receiver": get_user_model().objects.all().order_by("?").first(), + "token": "test-token", + "reason": "Test reason" + } + email = mail_builder.transfer_start(email_address, context) + email.send() + + context = { + "project": Project.objects.all().order_by("?").first(), + "old_owner": get_user_model().objects.all().order_by("?").first(), + "new_owner": get_user_model().objects.all().order_by("?").first(), + "reason": "Test reason" + } + email = mail_builder.transfer_accept(email_address, context) + email.send() + + context = { + "project": Project.objects.all().order_by("?").first(), + "rejecter": get_user_model().objects.all().order_by("?").first(), + "reason": "Test reason" + } + email = mail_builder.transfer_reject(email_address, context) + email.send() + + + # Contact with project admins email + project = Project.objects.all().order_by("?").first() + user = get_user_model().objects.all().order_by("?").first() + context = { + "full_name": user.get_full_name(), + "project_name": project.name, + "photo_url": get_user_photo_url(user), + "user_profile_url": resolve_front_url("user", user.username), + "project_settings_url": resolve_front_url("project-admin", project.slug), + "comment": "Test comment notification." + } + email = mail_builder.contact_notification(email_address, context) + email.send() + + # GitHub importer email + context = { + "project": Project.objects.all().order_by("?").first(), + "user": get_user_model().objects.all().order_by("?").first() + } + email = mail_builder.github_import_success(email_address, context) + email.send() + + # Jira importer email + context = { + "project": Project.objects.all().order_by("?").first(), + "user": get_user_model().objects.all().order_by("?").first() + } + email = mail_builder.jira_import_success(email_address, context) + email.send() + + # Trello importer email + context = { + "project": Project.objects.all().order_by("?").first(), + "user": get_user_model().objects.all().order_by("?").first() + } + email = mail_builder.trello_import_success(email_address, context) + email.send() + + # Asana importer email + context = { + "project": Project.objects.all().order_by("?").first(), + "user": get_user_model().objects.all().order_by("?").first() + } + email = mail_builder.asana_import_success(email_address, context) + email.send() + + # Error importer email + context = { + "user": get_user_model().objects.all().order_by("?").first(), + "error_subject": "Error importing GitHub project", + "error_message": "Error importing GitHub project", + "project": 1234, + "exception": "Exception message" + } + email = mail_builder.importer_import_error(email_address, context) + email.send() diff --git a/taiga/base/middleware/__init__.py b/taiga/base/middleware/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/middleware/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/middleware/cors.py b/taiga/base/middleware/cors.py new file mode 100644 index 000000000..d0f4339d9 --- /dev/null +++ b/taiga/base/middleware/cors.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django import http +from django.conf import settings + + +CORS_ALLOWED_ORIGINS = "*" +CORS_ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] +CORS_ALLOWED_HEADERS = ["content-type", "x-requested-with", + "authorization", "accept-encoding", + "x-disable-pagination", "x-lazy-pagination", + "x-host", "x-session-id", "set-orders"] +CORS_ALLOWED_CREDENTIALS = True +CORS_EXPOSE_HEADERS = ["x-pagination-count", "x-paginated", "x-paginated-by", + "x-pagination-current", "x-pagination-next", "x-pagination-prev", + "x-site-host", "x-site-register"] + +CORS_EXTRA_EXPOSE_HEADERS = getattr(settings, "APP_EXTRA_EXPOSE_HEADERS", []) + + +class CorsMiddleware(object): + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + self.process_request(request) + response = self.get_response(request) + self.process_response(request, response) + + return response + + def _populate_response(self, response): + response["Access-Control-Allow-Origin"] = CORS_ALLOWED_ORIGINS + response["Access-Control-Allow-Methods"] = ",".join(CORS_ALLOWED_METHODS) + response["Access-Control-Allow-Headers"] = ",".join(CORS_ALLOWED_HEADERS) + response["Access-Control-Expose-Headers"] = ",".join(CORS_EXPOSE_HEADERS + CORS_EXTRA_EXPOSE_HEADERS) + response["Access-Control-Max-Age"] = "1800" + + if CORS_ALLOWED_CREDENTIALS: + response["Access-Control-Allow-Credentials"] = "true" + + def process_request(self, request): + if "access-control-request-method" in request.headers: + response = http.HttpResponse() + self._populate_response(response) + return response + return None + + def process_response(self, request, response): + self._populate_response(response) + return response diff --git a/taiga/base/monkey.py b/taiga/base/monkey.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/monkey.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/neighbors.py b/taiga/base/neighbors.py new file mode 100644 index 000000000..60e52272a --- /dev/null +++ b/taiga/base/neighbors.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from collections import namedtuple + +from django.db import connection +from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import EmptyResultSet +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField + +Neighbor = namedtuple("Neighbor", "left right") + + +def get_neighbors(obj, results_set=None): + """Get the neighbors of a model instance. + + The neighbors are the objects that are at the left/right of `obj` in the results set. + + :param obj: The object you want to know its neighbors. + :param results_set: Find the neighbors applying the constraints of this set (a Django queryset + object). + + :return: Tuple `, `. Left and right neighbors can be `None`. + """ + if results_set is None: + results_set = type(obj).objects.get_queryset() + + # Neighbors calculation is at least at project level + results_set = results_set.filter(project_id=obj.project.id) + + compiler = results_set.query.get_compiler('default') + try: + base_sql, base_params = compiler.as_sql(with_col_aliases=True) + except EmptyResultSet: + # Generate a not empty queryset + results_set = type(obj).objects.get_queryset().filter(project_id=obj.project.id) + compiler = results_set.query.get_compiler('default') + base_sql, base_params = compiler.as_sql(with_col_aliases=True) + + query = """ + SELECT * FROM + (SELECT "col1" as id, + ROW_NUMBER() OVER(), + LAG("col1", 1) OVER() AS prev, + LEAD("col1", 1) OVER() AS next + FROM (%s) as ID_AND_ROW) + AS SELECTED_ID_AND_ROW + """ % (base_sql) + query += " WHERE id=%s;" + params = list(base_params) + [obj.id] + + cursor = connection.cursor() + cursor.execute(query, params) + row = cursor.fetchone() + if row is None: + return Neighbor(None, None) + + left_object_id = row[2] + right_object_id = row[3] + + try: + left = results_set.filter(id=left_object_id).first() + except ObjectDoesNotExist: + left = None + + try: + right = results_set.filter(id=right_object_id).first() + except ObjectDoesNotExist: + right = None + + return Neighbor(left, right) + + +class NeighborSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + + +class NeighborsSerializerMixin(serializers.LightSerializer): + neighbors = MethodField() + + def serialize_neighbor(self, neighbor): + if neighbor: + return NeighborSerializer(neighbor).data + return None + + def get_neighbors(self, obj): + view, request = self.context.get("view", None), self.context.get("request", None) + if view and request: + queryset = view.filter_queryset(view.get_queryset()) + left, right = get_neighbors(obj, results_set=queryset) + else: + left = right = None + + return { + "previous": self.serialize_neighbor(left), + "next": self.serialize_neighbor(right) + } diff --git a/taiga/base/response.py b/taiga/base/response.py new file mode 100644 index 000000000..2565f8d8e --- /dev/null +++ b/taiga/base/response.py @@ -0,0 +1,417 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""The various HTTP responses for use in returning proper HTTP codes.""" +from http.client import responses + +from django import http + +from django.template.response import SimpleTemplateResponse +import six + + +class Response(SimpleTemplateResponse): + """ + An HttpResponse that allows its data to be rendered into + arbitrary media types. + """ + def __init__(self, data=None, status=None, + template_name=None, headers=None, + exception=False, content_type=None): + """ + Alters the init arguments slightly. + For example, drop 'template_name', and instead use 'data'. + + Setting 'renderer' and 'media_type' will typically be deferred, + For example being set automatically by the `APIView`. + """ + super().__init__(None, status=status) + self.data = data + self.template_name = template_name + self.exception = exception + self.content_type = content_type + + if headers: + for name, value in six.iteritems(headers): + self[name] = value + + @property + def rendered_content(self): + renderer = getattr(self, "accepted_renderer", None) + media_type = getattr(self, "accepted_media_type", None) + context = getattr(self, "renderer_context", None) + + assert renderer, ".accepted_renderer not set on Response" + assert media_type, ".accepted_media_type not set on Response" + assert context, ".renderer_context not set on Response" + context["response"] = self + + charset = renderer.charset + content_type = self.content_type + + if content_type is None and charset is not None: + content_type = "{0}; charset={1}".format(media_type, charset) + elif content_type is None: + content_type = media_type + self["Content-Type"] = content_type + + ret = renderer.render(self.data, media_type, context) + if isinstance(ret, six.text_type): + assert charset, "renderer returned unicode, and did not specify " \ + "a charset value." + return bytes(ret.encode(charset)) + + if not ret: + del self["Content-Type"] + + return ret + + @property + def status_text(self): + """ + Returns reason text corresponding to our HTTP response status code. + Provided for convenience. + """ + # TODO: Deprecate and use a template tag instead + # TODO: Status code text for RFC 6585 status codes + return responses.get(self.status_code, '') + + def __getstate__(self): + """ + Remove attributes from the response that shouldn't be cached + """ + state = super().__getstate__() + for key in ("accepted_renderer", "renderer_context", "data"): + if key in state: + del state[key] + return state + + +class Ok(Response): + """200 OK + + Should be used to indicate nonspecific success. Must not be used to + communicate errors in the response body. + In most cases, 200 is the code the client hopes to see. It indicates that + the REST API successfully carried out whatever action the client requested, + and that no more specific code in the 2xx series is appropriate. Unlike + the 204 status code, a 200 response should include a response body. + """ + status_code = 200 + + +class Created(Response): + """201 Created + + Must be used to indicate successful resource creation. + A REST API responds with the 201 status code whenever a collection creates, + or a store adds, a new resource at the client's request. There may also be + times when a new resource is created as a result of some controller action, + in which case 201 would also be an appropriate response. + """ + status_code = 201 + + +class Accepted(Response): + """202 Accepted + + Must be used to indicate successful start of an asynchronous action. + A 202 response indicates that the client's request will be handled + asynchronously. This response status code tells the client that the request + appears valid, but it still may have problems once it's finally processed. + A 202 response is typically used for actions that take a long while to + process. + Controller resources may send 202 responses, but other resource types + should not. + """ + status_code = 202 + + +class NoContent(Response): + """204 No Content + + Should be used when the response body is intentionally empty. + The 204 status code is usually sent out in response to a PUT, POST, or + DELETE request, when the REST API declines to send back any status message + or representation in the response message's body. An API may also send 204 + in conjunction with a GET request to indicate that the requested resource + exists, but has no state representation to include in the body. + """ + status_code = 204 + + +class MultipleChoices(Response): + """300 Multiple Choices + + Indicates multiple options for the resource that the client may follow. + It could be used to present different format options for video, list files + with different extensions, or word sense disambiguation. + """ + status_code = 300 + + +class MovedPermanently(http.HttpResponsePermanentRedirect): + """301 Moved Permanently + + Should be used to relocate resources. + The 301 status code indicates that the REST API's resource model has been + significantly redesigned and a new permanent URI has been assigned to the + client's requested resource. The REST API should specify the new URI in + the response's Location header. + """ + status_code = 301 + + +class Found(http.HttpResponseRedirect): + """302 Found + + Should not be used. + The intended semantics of the 302 response code have been misunderstood + by programmers and incorrectly implemented in programs since version 1.0 + of the HTTP protocol. + The confusion centers on whether it is appropriate for a client to always + automatically issue a follow-up GET request to the URI in response's + Location header, regardless of the original request's method. For the + record, the intent of 302 is that this automatic redirect behavior only + applies if the client's original request used either the GET or HEAD + method. + To clear things up, HTTP 1.1 introduced status codes 303 ("See Other") + and 307 ("Temporary Redirect"), either of which should be used + instead of 302. + """ + status_code = 302 + + +class SeeOther(Response): + """303 See Other + + Should be used to refer the client to a different URI. + A 303 response indicates that a controller resource has finished its work, + but instead of sending a potentially unwanted response body, it sends the + client the URI of a response resource. This can be the URI of a temporary + status message, or the URI to some already existing, more permanent, + resource. + Generally speaking, the 303 status code allows a REST API to send a + reference to a resource without forcing the client to download its state. + Instead, the client may send a GET request to the value of the Location + header. + """ + status_code = 303 + + +class NotModified(http.HttpResponseNotModified): + """304 Not Modified + + Should be used to preserve bandwith. + This status code is similar to 204 ("No Content") in that the response body + must be empty. The key distinction is that 204 is used when there is + nothing to send in the body, whereas 304 is used when there is state + information associated with a resource but the client already has the most + recent version of the representation. + """ + status_code = 304 + + +class TemporaryRedirect(Response): + """307 Temporary Redirect + + Should be used to tell clients to resubmit the request to another URI. + HTTP/1.1 introduced the 307 status code to reiterate the originally + intended semantics of the 302 ("Found") status code. A 307 response + indicates that the REST API is not going to process the client's request. + Instead, the client should resubmit the request to the URI specified by + the response message's Location header. + A REST API can use this status code to assign a temporary URI to the + client's requested resource. For example, a 307 response can be used to + shift a client request over to another host. + """ + status_code = 307 + + +class BadRequest(Response): + """400 Bad Request + + May be used to indicate nonspecific failure. + 400 is the generic client-side error status, used when no other 4xx error + code is appropriate. + """ + status_code = 400 + + +class Unauthorized(Response): + """401 Unauthorized + + Must be used when there is a problem with the client credentials. + A 401 error response indicates that the client tried to operate on a + protected resource without providing the proper authorization. It may have + provided the wrong credentials or none at all. + """ + status_code = 401 + + +class Forbidden(Response): + """403 Forbidden + + Should be used to forbid access regardless of authorization state. + A 403 error response indicates that the client's request is formed + correctly, but the REST API refuses to honor it. A 403 response is not a + case of insufficient client credentials; that would be 401 ("Unauthorized"). + REST APIs use 403 to enforce application-level permissions. For example, a + client may be authorized to interact with some, but not all of a REST API's + resources. If the client attempts a resource interaction that is outside of + its permitted scope, the REST API should respond with 403. + """ + status_code = 403 + + +class NotFound(Response): + """404 Not Found + + Must be used when a client's URI cannot be mapped to a resource. + The 404 error status code indicates that the REST API can't map the + client's URI to a resource. + """ + status_code = 404 + + +class MethodNotAllowed(Response): + """405 Method Not Allowed + + Must be used when the HTTP method is not supported. + The API responds with a 405 error to indicate that the client tried to use + an HTTP method that the resource does not allow. For instance, a read-only + resource could support only GET and HEAD, while a controller resource might + allow GET and POST, but not PUT or DELETE. + A 405 response must include the Allow header, which lists the HTTP methods + that the resource supports. For example: + + Allow: GET, POST + + """ + status_code = 405 + + +class NotAcceptable(Response): + """406 Not Acceptable + + Must be used when the requested media type cannot be served. + The 406 error response indicates that the API is not able to generate any + of the client's preferred media types, as indicated by the Accept request + header. For example, a client request for data formatted as application/xml + will receive a 406 response if the API is only willing to format data as + application/json. + """ + status_code = 406 + + +class Conflict(Response): + """409 Conflict + + Should be used to indicate a violation of the resource state. + The 409 error response tells the client that they tried to put the REST + API's resources into an impossible or inconsistent state. For example, a + REST API may return this response code when a client tries to delete a + non-empty store resource. + """ + status_code = 409 + + +class Gone(Response): + """410 Gone + + Indicates that the resource requested is no longer available and will not + be available again. + This should be used when a resource has been intentionally removed and the + resource should be purged. Upon receiving a 410 status code, the client + should not request the resource again in the future. + """ + status_code = 410 + + +class PreconditionFailed(Response): + """412 Precondition Failed + + Should be used to support conditional operations. + The 412 error response indicates that the client specified one or more + preconditions in its request headers, effectively telling the REST API to + carry out its request only if certain conditions were met. + A 412 response indicates that those conditions were not met, so instead of + carrying out the request, the API sends this status code. + """ + status_code = 412 + + +class UnsupportedMediaType(Response): + """415 Unsupported Media Type + + Must be used when the media type of a request's payload cannot be processed. + The 415 error response indicates that the API is not able to process the + client's supplied media type, as indicated by the Content-Type request + header. + For example, a client request including data formatted as application/xml + will receive a 415 response if the API is only willing to process data + formatted as application/json. + """ + status_code = 415 + + +class TooManyRequests(Response): + """429 Too Many Requests + + The user has sent too many requests in a given amount of time. + Intended for use with rate limiting schemes. + """ + status_code = 429 + + +class InternalServerError(Response): + """500 Internal Server Error + + Should be used to indicate API malfunction. + 500 is the generic REST API error response. Most web frameworks + automatically respond with this response status code whenever they execute + some request handler code that raises an exception. + A 500 error is never the client's fault and therefore it is reasonable for + the client to retry the exact same request that triggered this response, + and hope to get a different response. + """ + status_code = 500 + + +class NotImplemented(Response): + """501 Not Implemented + + The server either does not recognise the request method, or it lacks the + ability to fulfill the request. + """ + status_code = 501 diff --git a/taiga/base/routers.py b/taiga/base/routers.py new file mode 100644 index 000000000..7278392e7 --- /dev/null +++ b/taiga/base/routers.py @@ -0,0 +1,364 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Django Rest Framework 2.4.0 routers module (should be removed when 2.4 is released) + +import itertools +from collections import namedtuple +from django.urls import path, re_path +from django.core.exceptions import ImproperlyConfigured +from django.urls import NoReverseMatch, URLResolver + +from taiga.base.api import views +from taiga.base import response +from taiga.base.api.reverse import reverse +from taiga.base.api.urlpatterns import format_suffix_patterns + + +Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) +DynamicDetailRoute = namedtuple('DynamicDetailRoute', ['url', 'name', 'initkwargs']) +DynamicListRoute = namedtuple('DynamicListRoute', ['url', 'name', 'initkwargs']) + + +def replace_methodname(format_string, methodname): + """ + Partially format a format_string, swapping out any + '{methodname}' or '{methodnamehyphen}' components. + """ + methodnamehyphen = methodname.replace('_', '-') + ret = format_string + ret = ret.replace('{methodname}', methodname) + ret = ret.replace('{methodnamehyphen}', methodnamehyphen) + return ret + + +def flatten(list_of_lists): + """ + Takes an iterable of iterables, returns a single iterable containing all items + """ + return itertools.chain(*list_of_lists) + + +class BaseRouter(object): + def __init__(self): + self.registry = [] + + def register(self, prefix, viewset, base_name=None): + if base_name is None: + base_name = self.get_default_base_name(viewset) + self.registry.append((prefix, viewset, base_name)) + + def get_default_base_name(self, viewset): + """ + If `base_name` is not specified, attempt to automatically determine + it from the viewset. + """ + raise NotImplemented('get_default_base_name must be overridden') + + def get_urls(self): + """ + Return a list of URL patterns, given the registered viewsets. + """ + raise NotImplemented('get_urls must be overridden') + + @property + def urls(self): + if not hasattr(self, '_urls'): + self._urls = self.get_urls() + return self._urls + + +class SimpleRouter(BaseRouter): + routes = [ + # List route. + Route( + url=r'^{prefix}{trailing_slash}$', + mapping={ + 'get': 'list', + 'post': 'create' + }, + name='{basename}-list', + initkwargs={'suffix': 'List'} + ), + # Dynamically generated list routes. + # Generated using @list_route decorator + # on methods of the viewset. + DynamicListRoute( + url=r'^{prefix}/{methodname}{trailing_slash}$', + name='{basename}-{methodnamehyphen}', + initkwargs={} + ), + # Detail route. + Route( + url=r'^{prefix}/{lookup}{trailing_slash}$', + mapping={ + 'get': 'retrieve', + 'put': 'update', + 'patch': 'partial_update', + 'delete': 'destroy' + }, + name='{basename}-detail', + initkwargs={'suffix': 'Instance'} + ), + # Dynamically generated detail routes. + # Generated using @detail_route decorator on methods of the viewset. + DynamicDetailRoute( + url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', + name='{basename}-{methodnamehyphen}', + initkwargs={} + ), + ] + + def __init__(self, trailing_slash=True): + self.trailing_slash = trailing_slash and '/' or '' + super(SimpleRouter, self).__init__() + + def get_default_base_name(self, viewset): + """ + If `base_name` is not specified, attempt to automatically determine + it from the viewset. + """ + model_cls = getattr(viewset, 'model', None) + queryset = getattr(viewset, 'queryset', None) + if model_cls is None and queryset is not None: + model_cls = queryset.model + + assert model_cls, '`base_name` argument not specified, and could ' \ + 'not automatically determine the name from the viewset, as ' \ + 'it does not have a `.model` or `.queryset` attribute.' + + return model_cls._meta.object_name.lower() + + def get_routes(self, viewset): + """ + Augment `self.routes` with any dynamically generated routes. + + Returns a list of the Route namedtuple. + """ + + known_actions = flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)]) + + # Determine any `@detail_route` or `@list_route` decorated methods on the viewset + detail_routes = [] + list_routes = [] + for methodname in dir(viewset): + attr = getattr(viewset, methodname) + httpmethods = getattr(attr, 'bind_to_methods', None) + detail = getattr(attr, 'detail', True) + if httpmethods: + if methodname in known_actions: + raise ImproperlyConfigured('Cannot use @detail_route or @list_route ' + 'decorators on method "%s" ' + 'as it is an existing route' % methodname) + httpmethods = [method.lower() for method in httpmethods] + if detail: + detail_routes.append((httpmethods, methodname)) + else: + list_routes.append((httpmethods, methodname)) + + ret = [] + for route in self.routes: + if isinstance(route, DynamicDetailRoute): + # Dynamic detail routes (@detail_route decorator) + for httpmethods, methodname in detail_routes: + initkwargs = route.initkwargs.copy() + initkwargs.update(getattr(viewset, methodname).kwargs) + ret.append(Route( + url=replace_methodname(route.url, methodname), + mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), + name=replace_methodname(route.name, methodname), + initkwargs=initkwargs, + )) + elif isinstance(route, DynamicListRoute): + # Dynamic list routes (@list_route decorator) + for httpmethods, methodname in list_routes: + initkwargs = route.initkwargs.copy() + initkwargs.update(getattr(viewset, methodname).kwargs) + ret.append(Route( + url=replace_methodname(route.url, methodname), + mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), + name=replace_methodname(route.name, methodname), + initkwargs=initkwargs, + )) + else: + # Standard route + ret.append(route) + + return ret + + def get_method_map(self, viewset, method_map): + """ + Given a viewset, and a mapping of http methods to actions, + return a new mapping which only includes any mappings that + are actually implemented by the viewset. + """ + bound_methods = {} + for method, action in method_map.items(): + if hasattr(viewset, action): + bound_methods[method] = action + return bound_methods + + def get_lookup_regex(self, viewset, lookup_prefix=''): + """ + Given a viewset, return the portion of URL regex that is used + to match against a single instance. + + Note that lookup_prefix is not used directly inside REST rest_framework + itself, but is required in order to nicely support nested router + implementations, such as drf-nested-routers. + + https://github.com/alanjds/drf-nested-routers + """ + base_regex = '(?P<{lookup_prefix}{lookup_field}>{lookup_value})' + # Use `pk` as default field, unset set. Default regex should not + # consume `.json` style suffixes and should break at '/' boundaries. + lookup_field = getattr(viewset, 'lookup_field', 'pk') + lookup_value = getattr(viewset, 'lookup_value_regex', '[^/.]+') + return base_regex.format( + lookup_prefix=lookup_prefix, + lookup_field=lookup_field, + lookup_value=lookup_value + ) + + def get_urls(self): + """ + Use the registered viewsets to generate a list of URL patterns. + """ + ret = [] + + for prefix, viewset, basename in self.registry: + lookup = self.get_lookup_regex(viewset) + routes = self.get_routes(viewset) + + for route in routes: + + # Only actions which actually exist on the viewset will be bound + mapping = self.get_method_map(viewset, route.mapping) + if not mapping: + continue + + # Build the url pattern + regex = route.url.format( + prefix=prefix, + lookup=lookup, + trailing_slash=self.trailing_slash + ) + view = viewset.as_view(mapping, **route.initkwargs) + name = route.name.format(basename=basename) + ret.append(re_path(regex, view, name=name)) + + return ret + + +class DRFDefaultRouter(SimpleRouter): + """ + The default router extends the SimpleRouter, but also adds in a default + API root view, and adds format suffix patterns to the URLs. + """ + include_root_view = True + include_format_suffixes = True + root_view_name = 'api-root' + + def get_api_root_view(self): + """ + Return a view to use as the API root. + """ + api_root_dict = {} + list_name = self.routes[0].name + for prefix, viewset, basename in self.registry: + api_root_dict[prefix] = list_name.format(basename=basename) + + class APIRoot(views.APIView): + _ignore_model_permissions = True + + def get(self, request, format=None): + ret = {} + for key, url_name in api_root_dict.items(): + try: + ret[key] = reverse(url_name, request=request, format=format) + except NoReverseMatch: + # Support resources that are prefixed by a parametrized url + ret[key] = request.build_absolute_uri() + key + return response.Response(ret) + + return APIRoot.as_view() + + def get_urls(self): + """ + Generate the list of URL patterns, including a default root view + for the API, and appending `.json` style format suffixes. + """ + urls = [] + + if self.include_root_view: + root_url = path('', self.get_api_root_view(), name=self.root_view_name) + urls.append(root_url) + + default_urls = super(DRFDefaultRouter, self).get_urls() + urls.extend(default_urls) + + if self.include_format_suffixes: + urls = format_suffix_patterns(urls) + + return urls + + +class NestedRegistryItem(object): + def __init__(self, router, parent_prefix, parent_item=None): + self.router = router + self.parent_prefix = parent_prefix + self.parent_item = parent_item + + def register(self, prefix, viewset, base_name, parents_query_lookups): + self.router._register( + prefix=self.get_prefix(current_prefix=prefix, parents_query_lookups=parents_query_lookups), + viewset=viewset, + base_name=base_name, + ) + return NestedRegistryItem( + router=self.router, + parent_prefix=prefix, + parent_item=self + ) + + def get_prefix(self, current_prefix, parents_query_lookups): + return "{0}/{1}".format( + self.get_parent_prefix(parents_query_lookups), + current_prefix + ) + + def get_parent_prefix(self, parents_query_lookups): + prefix = "/" + current_item = self + i = len(parents_query_lookups) - 1 + while current_item: + prefix = "{parent_prefix}/(?P<{parent_pk_kwarg_name}>[^/.]+)/{prefix}".format( + parent_prefix=current_item.parent_prefix, + parent_pk_kwarg_name=parents_query_lookups[i], + prefix=prefix + ) + i -= 1 + current_item = current_item.parent_item + return prefix.strip("/") + + +class NestedRouterMixin: + def _register(self, *args, **kwargs): + return super().register(*args, **kwargs) + + def register(self, *args, **kwargs): + self._register(*args, **kwargs) + return NestedRegistryItem( + router=self, + parent_prefix=self.registry[-1][0] + ) + + +class DefaultRouter(NestedRouterMixin, DRFDefaultRouter): + pass + +__all__ = ["DefaultRouter"] diff --git a/taiga/base/signals/__init__.py b/taiga/base/signals/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/signals/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/signals/cleanup_files.py b/taiga/base/signals/cleanup_files.py new file mode 100644 index 000000000..399eaa00e --- /dev/null +++ b/taiga/base/signals/cleanup_files.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.db import models, connection +from django.db.models.signals import pre_save, post_delete +from django.dispatch import Signal + +import logging + +logger = logging.getLogger(__name__) + + +cleanup_pre_delete = Signal() +cleanup_post_delete = Signal() + + +def _find_models_with_filefield(): + result = [] + for model in apps.get_models(): + for field in model._meta.fields: + if isinstance(field, models.FileField): + result.append(model) + break + return result + + +def _delete_file(file_obj): + def delete_from_storage(): + try: + cleanup_pre_delete.send(sender=None, file=file_obj) + storage.delete(file_obj.name) + cleanup_post_delete.send(sender=None, file=file_obj) + except Exception: + logger.exception("Unexpected exception while attempting " + "to delete old file '%s'".format(file_obj.name)) + + storage = file_obj.storage + if storage and storage.exists(file_obj.name): + connection.on_commit(delete_from_storage) + + +def _get_file_fields(instance): + return filter( + lambda field: isinstance(field, models.FileField), + instance._meta.fields, + ) + + +def remove_files_on_change(sender, instance, **kwargs): + if not instance.pk: + return + + try: + old_instance = sender.objects.get(pk=instance.pk) + except instance.DoesNotExist: + return + + for field in _get_file_fields(instance): + old_file = getattr(old_instance, field.name) + new_file = getattr(instance, field.name) + + if old_file and old_file != new_file: + _delete_file(old_file) + + +def remove_files_on_delete(sender, instance, **kwargs): + for field in _get_file_fields(instance): + file_to_delete = getattr(instance, field.name) + + if file_to_delete: + _delete_file(file_to_delete) + + +def connect_cleanup_files_signals(): + for model in _find_models_with_filefield(): + pre_save.connect(remove_files_on_change, sender=model) + post_delete.connect(remove_files_on_delete, sender=model) diff --git a/taiga/base/signals/thumbnails.py b/taiga/base/signals/thumbnails.py new file mode 100644 index 000000000..558d1e3dc --- /dev/null +++ b/taiga/base/signals/thumbnails.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from .cleanup_files import cleanup_post_delete +from easy_thumbnails.files import get_thumbnailer + + +def _delete_thumbnail_files(**kwargs): + thumbnailer = get_thumbnailer(kwargs["file"]) + thumbnailer.delete_thumbnails() + + +def connect_thumbnail_signals(): + cleanup_post_delete.connect(_delete_thumbnail_files) + + +def disconnect_thumbnail_signals(): + cleanup_post_delete.disconnect(_delete_thumbnail_files) diff --git a/taiga/base/static/emails/logo-color.png b/taiga/base/static/emails/logo-color.png new file mode 100644 index 000000000..7a1e3a9cb Binary files /dev/null and b/taiga/base/static/emails/logo-color.png differ diff --git a/taiga/base/static/emails/logo-github.png b/taiga/base/static/emails/logo-github.png new file mode 100644 index 000000000..16d5deebc Binary files /dev/null and b/taiga/base/static/emails/logo-github.png differ diff --git a/taiga/base/static/emails/logo-twitter.png b/taiga/base/static/emails/logo-twitter.png new file mode 100644 index 000000000..da6e89177 Binary files /dev/null and b/taiga/base/static/emails/logo-twitter.png differ diff --git a/taiga/base/static/emails/logo-web.png b/taiga/base/static/emails/logo-web.png new file mode 100644 index 000000000..f51fb0825 Binary files /dev/null and b/taiga/base/static/emails/logo-web.png differ diff --git a/taiga/base/static/emails/logo.png b/taiga/base/static/emails/logo.png new file mode 100644 index 000000000..612f19e05 Binary files /dev/null and b/taiga/base/static/emails/logo.png differ diff --git a/taiga/base/static/emails/top-bg-hero.png b/taiga/base/static/emails/top-bg-hero.png new file mode 100644 index 000000000..9bb905bfb Binary files /dev/null and b/taiga/base/static/emails/top-bg-hero.png differ diff --git a/taiga/base/static/emails/top-bg-update.png b/taiga/base/static/emails/top-bg-update.png new file mode 100644 index 000000000..928636df1 Binary files /dev/null and b/taiga/base/static/emails/top-bg-update.png differ diff --git a/taiga/base/static/img/emojis/+1.png b/taiga/base/static/img/emojis/+1.png new file mode 100644 index 000000000..be57e612c Binary files /dev/null and b/taiga/base/static/img/emojis/+1.png differ diff --git a/taiga/base/static/img/emojis/-1.png b/taiga/base/static/img/emojis/-1.png new file mode 100644 index 000000000..32c794435 Binary files /dev/null and b/taiga/base/static/img/emojis/-1.png differ diff --git a/taiga/base/static/img/emojis/100.png b/taiga/base/static/img/emojis/100.png new file mode 100644 index 000000000..1116851c1 Binary files /dev/null and b/taiga/base/static/img/emojis/100.png differ diff --git a/taiga/base/static/img/emojis/1234.png b/taiga/base/static/img/emojis/1234.png new file mode 100644 index 000000000..fd009c43e Binary files /dev/null and b/taiga/base/static/img/emojis/1234.png differ diff --git a/taiga/base/static/img/emojis/8ball.png b/taiga/base/static/img/emojis/8ball.png new file mode 100644 index 000000000..b88095f11 Binary files /dev/null and b/taiga/base/static/img/emojis/8ball.png differ diff --git a/taiga/base/static/img/emojis/a.png b/taiga/base/static/img/emojis/a.png new file mode 100644 index 000000000..161ea0a85 Binary files /dev/null and b/taiga/base/static/img/emojis/a.png differ diff --git a/taiga/base/static/img/emojis/ab.png b/taiga/base/static/img/emojis/ab.png new file mode 100644 index 000000000..7814a89e8 Binary files /dev/null and b/taiga/base/static/img/emojis/ab.png differ diff --git a/taiga/base/static/img/emojis/abc.png b/taiga/base/static/img/emojis/abc.png new file mode 100644 index 000000000..987448a60 Binary files /dev/null and b/taiga/base/static/img/emojis/abc.png differ diff --git a/taiga/base/static/img/emojis/abcd.png b/taiga/base/static/img/emojis/abcd.png new file mode 100644 index 000000000..270340959 Binary files /dev/null and b/taiga/base/static/img/emojis/abcd.png differ diff --git a/taiga/base/static/img/emojis/accept.png b/taiga/base/static/img/emojis/accept.png new file mode 100644 index 000000000..f1b14ca7b Binary files /dev/null and b/taiga/base/static/img/emojis/accept.png differ diff --git a/taiga/base/static/img/emojis/aerial_tramway.png b/taiga/base/static/img/emojis/aerial_tramway.png new file mode 100644 index 000000000..271a40bde Binary files /dev/null and b/taiga/base/static/img/emojis/aerial_tramway.png differ diff --git a/taiga/base/static/img/emojis/airplane.png b/taiga/base/static/img/emojis/airplane.png new file mode 100644 index 000000000..8788a19b9 Binary files /dev/null and b/taiga/base/static/img/emojis/airplane.png differ diff --git a/taiga/base/static/img/emojis/alarm_clock.png b/taiga/base/static/img/emojis/alarm_clock.png new file mode 100644 index 000000000..f24b8b986 Binary files /dev/null and b/taiga/base/static/img/emojis/alarm_clock.png differ diff --git a/taiga/base/static/img/emojis/alien.png b/taiga/base/static/img/emojis/alien.png new file mode 100644 index 000000000..2ec10c7f3 Binary files /dev/null and b/taiga/base/static/img/emojis/alien.png differ diff --git a/taiga/base/static/img/emojis/ambulance.png b/taiga/base/static/img/emojis/ambulance.png new file mode 100644 index 000000000..ba70b9cb9 Binary files /dev/null and b/taiga/base/static/img/emojis/ambulance.png differ diff --git a/taiga/base/static/img/emojis/anchor.png b/taiga/base/static/img/emojis/anchor.png new file mode 100644 index 000000000..c29afa5bd Binary files /dev/null and b/taiga/base/static/img/emojis/anchor.png differ diff --git a/taiga/base/static/img/emojis/angel.png b/taiga/base/static/img/emojis/angel.png new file mode 100644 index 000000000..c8cb8d05f Binary files /dev/null and b/taiga/base/static/img/emojis/angel.png differ diff --git a/taiga/base/static/img/emojis/anger.png b/taiga/base/static/img/emojis/anger.png new file mode 100644 index 000000000..3c120b3f2 Binary files /dev/null and b/taiga/base/static/img/emojis/anger.png differ diff --git a/taiga/base/static/img/emojis/angry.png b/taiga/base/static/img/emojis/angry.png new file mode 100644 index 000000000..f7b098369 Binary files /dev/null and b/taiga/base/static/img/emojis/angry.png differ diff --git a/taiga/base/static/img/emojis/anguished.png b/taiga/base/static/img/emojis/anguished.png new file mode 100644 index 000000000..f9a25060c Binary files /dev/null and b/taiga/base/static/img/emojis/anguished.png differ diff --git a/taiga/base/static/img/emojis/ant.png b/taiga/base/static/img/emojis/ant.png new file mode 100644 index 000000000..89cdca8c8 Binary files /dev/null and b/taiga/base/static/img/emojis/ant.png differ diff --git a/taiga/base/static/img/emojis/apple.png b/taiga/base/static/img/emojis/apple.png new file mode 100644 index 000000000..987e85a01 Binary files /dev/null and b/taiga/base/static/img/emojis/apple.png differ diff --git a/taiga/base/static/img/emojis/aquarius.png b/taiga/base/static/img/emojis/aquarius.png new file mode 100644 index 000000000..8891f7b83 Binary files /dev/null and b/taiga/base/static/img/emojis/aquarius.png differ diff --git a/taiga/base/static/img/emojis/aries.png b/taiga/base/static/img/emojis/aries.png new file mode 100644 index 000000000..83c38f109 Binary files /dev/null and b/taiga/base/static/img/emojis/aries.png differ diff --git a/taiga/base/static/img/emojis/arrow_backward.png b/taiga/base/static/img/emojis/arrow_backward.png new file mode 100644 index 000000000..daf003a22 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_backward.png differ diff --git a/taiga/base/static/img/emojis/arrow_double_down.png b/taiga/base/static/img/emojis/arrow_double_down.png new file mode 100644 index 000000000..a5a701a8d Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_double_down.png differ diff --git a/taiga/base/static/img/emojis/arrow_double_up.png b/taiga/base/static/img/emojis/arrow_double_up.png new file mode 100644 index 000000000..560d7ff61 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_double_up.png differ diff --git a/taiga/base/static/img/emojis/arrow_down.png b/taiga/base/static/img/emojis/arrow_down.png new file mode 100644 index 000000000..e6ec2198e Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_down.png differ diff --git a/taiga/base/static/img/emojis/arrow_down_small.png b/taiga/base/static/img/emojis/arrow_down_small.png new file mode 100644 index 000000000..c01ef5f40 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_down_small.png differ diff --git a/taiga/base/static/img/emojis/arrow_forward.png b/taiga/base/static/img/emojis/arrow_forward.png new file mode 100644 index 000000000..a9e553ae5 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_forward.png differ diff --git a/taiga/base/static/img/emojis/arrow_heading_down.png b/taiga/base/static/img/emojis/arrow_heading_down.png new file mode 100644 index 000000000..15b295729 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_heading_down.png differ diff --git a/taiga/base/static/img/emojis/arrow_heading_up.png b/taiga/base/static/img/emojis/arrow_heading_up.png new file mode 100644 index 000000000..efecd616a Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_heading_up.png differ diff --git a/taiga/base/static/img/emojis/arrow_left.png b/taiga/base/static/img/emojis/arrow_left.png new file mode 100644 index 000000000..74d6eb4c7 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_left.png differ diff --git a/taiga/base/static/img/emojis/arrow_lower_left.png b/taiga/base/static/img/emojis/arrow_lower_left.png new file mode 100644 index 000000000..83444b256 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_lower_left.png differ diff --git a/taiga/base/static/img/emojis/arrow_lower_right.png b/taiga/base/static/img/emojis/arrow_lower_right.png new file mode 100644 index 000000000..076a4667e Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_lower_right.png differ diff --git a/taiga/base/static/img/emojis/arrow_right.png b/taiga/base/static/img/emojis/arrow_right.png new file mode 100644 index 000000000..d77195932 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_right.png differ diff --git a/taiga/base/static/img/emojis/arrow_right_hook.png b/taiga/base/static/img/emojis/arrow_right_hook.png new file mode 100644 index 000000000..13acfac8a Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_right_hook.png differ diff --git a/taiga/base/static/img/emojis/arrow_up.png b/taiga/base/static/img/emojis/arrow_up.png new file mode 100644 index 000000000..6072d17ad Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_up.png differ diff --git a/taiga/base/static/img/emojis/arrow_up_down.png b/taiga/base/static/img/emojis/arrow_up_down.png new file mode 100644 index 000000000..af79dc5bb Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_up_down.png differ diff --git a/taiga/base/static/img/emojis/arrow_up_small.png b/taiga/base/static/img/emojis/arrow_up_small.png new file mode 100644 index 000000000..00185de09 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_up_small.png differ diff --git a/taiga/base/static/img/emojis/arrow_upper_left.png b/taiga/base/static/img/emojis/arrow_upper_left.png new file mode 100644 index 000000000..ff1613178 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_upper_left.png differ diff --git a/taiga/base/static/img/emojis/arrow_upper_right.png b/taiga/base/static/img/emojis/arrow_upper_right.png new file mode 100644 index 000000000..e47ecc904 Binary files /dev/null and b/taiga/base/static/img/emojis/arrow_upper_right.png differ diff --git a/taiga/base/static/img/emojis/arrows_clockwise.png b/taiga/base/static/img/emojis/arrows_clockwise.png new file mode 100644 index 000000000..9ec27419d Binary files /dev/null and b/taiga/base/static/img/emojis/arrows_clockwise.png differ diff --git a/taiga/base/static/img/emojis/arrows_counterclockwise.png b/taiga/base/static/img/emojis/arrows_counterclockwise.png new file mode 100644 index 000000000..a8987d18d Binary files /dev/null and b/taiga/base/static/img/emojis/arrows_counterclockwise.png differ diff --git a/taiga/base/static/img/emojis/art.png b/taiga/base/static/img/emojis/art.png new file mode 100644 index 000000000..80a87b3f8 Binary files /dev/null and b/taiga/base/static/img/emojis/art.png differ diff --git a/taiga/base/static/img/emojis/articulated_lorry.png b/taiga/base/static/img/emojis/articulated_lorry.png new file mode 100644 index 000000000..aa5beaa01 Binary files /dev/null and b/taiga/base/static/img/emojis/articulated_lorry.png differ diff --git a/taiga/base/static/img/emojis/astonished.png b/taiga/base/static/img/emojis/astonished.png new file mode 100644 index 000000000..e85e20d8b Binary files /dev/null and b/taiga/base/static/img/emojis/astonished.png differ diff --git a/taiga/base/static/img/emojis/atm.png b/taiga/base/static/img/emojis/atm.png new file mode 100644 index 000000000..5f09fb868 Binary files /dev/null and b/taiga/base/static/img/emojis/atm.png differ diff --git a/taiga/base/static/img/emojis/b.png b/taiga/base/static/img/emojis/b.png new file mode 100644 index 000000000..1bfff8896 Binary files /dev/null and b/taiga/base/static/img/emojis/b.png differ diff --git a/taiga/base/static/img/emojis/baby.png b/taiga/base/static/img/emojis/baby.png new file mode 100644 index 000000000..40a111642 Binary files /dev/null and b/taiga/base/static/img/emojis/baby.png differ diff --git a/taiga/base/static/img/emojis/baby_bottle.png b/taiga/base/static/img/emojis/baby_bottle.png new file mode 100644 index 000000000..f6516f055 Binary files /dev/null and b/taiga/base/static/img/emojis/baby_bottle.png differ diff --git a/taiga/base/static/img/emojis/baby_chick.png b/taiga/base/static/img/emojis/baby_chick.png new file mode 100644 index 000000000..662bed8ca Binary files /dev/null and b/taiga/base/static/img/emojis/baby_chick.png differ diff --git a/taiga/base/static/img/emojis/baby_symbol.png b/taiga/base/static/img/emojis/baby_symbol.png new file mode 100644 index 000000000..f7e3bc6dd Binary files /dev/null and b/taiga/base/static/img/emojis/baby_symbol.png differ diff --git a/taiga/base/static/img/emojis/baggage_claim.png b/taiga/base/static/img/emojis/baggage_claim.png new file mode 100644 index 000000000..c296e8511 Binary files /dev/null and b/taiga/base/static/img/emojis/baggage_claim.png differ diff --git a/taiga/base/static/img/emojis/balloon.png b/taiga/base/static/img/emojis/balloon.png new file mode 100644 index 000000000..4176a57f3 Binary files /dev/null and b/taiga/base/static/img/emojis/balloon.png differ diff --git a/taiga/base/static/img/emojis/ballot_box_with_check.png b/taiga/base/static/img/emojis/ballot_box_with_check.png new file mode 100644 index 000000000..936b2daeb Binary files /dev/null and b/taiga/base/static/img/emojis/ballot_box_with_check.png differ diff --git a/taiga/base/static/img/emojis/bamboo.png b/taiga/base/static/img/emojis/bamboo.png new file mode 100644 index 000000000..a1bded7ec Binary files /dev/null and b/taiga/base/static/img/emojis/bamboo.png differ diff --git a/taiga/base/static/img/emojis/banana.png b/taiga/base/static/img/emojis/banana.png new file mode 100644 index 000000000..36adf23dd Binary files /dev/null and b/taiga/base/static/img/emojis/banana.png differ diff --git a/taiga/base/static/img/emojis/bangbang.png b/taiga/base/static/img/emojis/bangbang.png new file mode 100644 index 000000000..8fcd1c585 Binary files /dev/null and b/taiga/base/static/img/emojis/bangbang.png differ diff --git a/taiga/base/static/img/emojis/bank.png b/taiga/base/static/img/emojis/bank.png new file mode 100644 index 000000000..43078c08d Binary files /dev/null and b/taiga/base/static/img/emojis/bank.png differ diff --git a/taiga/base/static/img/emojis/bar_chart.png b/taiga/base/static/img/emojis/bar_chart.png new file mode 100644 index 000000000..92c57b2fc Binary files /dev/null and b/taiga/base/static/img/emojis/bar_chart.png differ diff --git a/taiga/base/static/img/emojis/barber.png b/taiga/base/static/img/emojis/barber.png new file mode 100644 index 000000000..4706e1140 Binary files /dev/null and b/taiga/base/static/img/emojis/barber.png differ diff --git a/taiga/base/static/img/emojis/baseball.png b/taiga/base/static/img/emojis/baseball.png new file mode 100644 index 000000000..6ffa6e405 Binary files /dev/null and b/taiga/base/static/img/emojis/baseball.png differ diff --git a/taiga/base/static/img/emojis/basketball.png b/taiga/base/static/img/emojis/basketball.png new file mode 100644 index 000000000..9552ff186 Binary files /dev/null and b/taiga/base/static/img/emojis/basketball.png differ diff --git a/taiga/base/static/img/emojis/bath.png b/taiga/base/static/img/emojis/bath.png new file mode 100644 index 000000000..ba40c0e80 Binary files /dev/null and b/taiga/base/static/img/emojis/bath.png differ diff --git a/taiga/base/static/img/emojis/bathtub.png b/taiga/base/static/img/emojis/bathtub.png new file mode 100644 index 000000000..b65340a1e Binary files /dev/null and b/taiga/base/static/img/emojis/bathtub.png differ diff --git a/taiga/base/static/img/emojis/battery.png b/taiga/base/static/img/emojis/battery.png new file mode 100644 index 000000000..b99180596 Binary files /dev/null and b/taiga/base/static/img/emojis/battery.png differ diff --git a/taiga/base/static/img/emojis/bear.png b/taiga/base/static/img/emojis/bear.png new file mode 100644 index 000000000..fc39613ff Binary files /dev/null and b/taiga/base/static/img/emojis/bear.png differ diff --git a/taiga/base/static/img/emojis/bee.png b/taiga/base/static/img/emojis/bee.png new file mode 100644 index 000000000..1c5fc5bf0 Binary files /dev/null and b/taiga/base/static/img/emojis/bee.png differ diff --git a/taiga/base/static/img/emojis/beer.png b/taiga/base/static/img/emojis/beer.png new file mode 100644 index 000000000..7e977b340 Binary files /dev/null and b/taiga/base/static/img/emojis/beer.png differ diff --git a/taiga/base/static/img/emojis/beers.png b/taiga/base/static/img/emojis/beers.png new file mode 100644 index 000000000..532b785be Binary files /dev/null and b/taiga/base/static/img/emojis/beers.png differ diff --git a/taiga/base/static/img/emojis/beetle.png b/taiga/base/static/img/emojis/beetle.png new file mode 100644 index 000000000..50959bb82 Binary files /dev/null and b/taiga/base/static/img/emojis/beetle.png differ diff --git a/taiga/base/static/img/emojis/beginner.png b/taiga/base/static/img/emojis/beginner.png new file mode 100644 index 000000000..e4e0f0adc Binary files /dev/null and b/taiga/base/static/img/emojis/beginner.png differ diff --git a/taiga/base/static/img/emojis/bell.png b/taiga/base/static/img/emojis/bell.png new file mode 100644 index 000000000..46a839860 Binary files /dev/null and b/taiga/base/static/img/emojis/bell.png differ diff --git a/taiga/base/static/img/emojis/bento.png b/taiga/base/static/img/emojis/bento.png new file mode 100644 index 000000000..459c924fd Binary files /dev/null and b/taiga/base/static/img/emojis/bento.png differ diff --git a/taiga/base/static/img/emojis/bicyclist.png b/taiga/base/static/img/emojis/bicyclist.png new file mode 100644 index 000000000..39698d441 Binary files /dev/null and b/taiga/base/static/img/emojis/bicyclist.png differ diff --git a/taiga/base/static/img/emojis/bike.png b/taiga/base/static/img/emojis/bike.png new file mode 100644 index 000000000..bb4ae9f1a Binary files /dev/null and b/taiga/base/static/img/emojis/bike.png differ diff --git a/taiga/base/static/img/emojis/bikini.png b/taiga/base/static/img/emojis/bikini.png new file mode 100644 index 000000000..6f80c3cf5 Binary files /dev/null and b/taiga/base/static/img/emojis/bikini.png differ diff --git a/taiga/base/static/img/emojis/bird.png b/taiga/base/static/img/emojis/bird.png new file mode 100644 index 000000000..8d9f01d2b Binary files /dev/null and b/taiga/base/static/img/emojis/bird.png differ diff --git a/taiga/base/static/img/emojis/birthday.png b/taiga/base/static/img/emojis/birthday.png new file mode 100644 index 000000000..62e9e2df9 Binary files /dev/null and b/taiga/base/static/img/emojis/birthday.png differ diff --git a/taiga/base/static/img/emojis/black_circle.png b/taiga/base/static/img/emojis/black_circle.png new file mode 100644 index 000000000..d2ac4a6ac Binary files /dev/null and b/taiga/base/static/img/emojis/black_circle.png differ diff --git a/taiga/base/static/img/emojis/black_joker.png b/taiga/base/static/img/emojis/black_joker.png new file mode 100644 index 000000000..7bc34c4cd Binary files /dev/null and b/taiga/base/static/img/emojis/black_joker.png differ diff --git a/taiga/base/static/img/emojis/black_nib.png b/taiga/base/static/img/emojis/black_nib.png new file mode 100644 index 000000000..45482510b Binary files /dev/null and b/taiga/base/static/img/emojis/black_nib.png differ diff --git a/taiga/base/static/img/emojis/black_square.png b/taiga/base/static/img/emojis/black_square.png new file mode 100644 index 000000000..730cb1184 Binary files /dev/null and b/taiga/base/static/img/emojis/black_square.png differ diff --git a/taiga/base/static/img/emojis/black_square_button.png b/taiga/base/static/img/emojis/black_square_button.png new file mode 100644 index 000000000..c56f87e16 Binary files /dev/null and b/taiga/base/static/img/emojis/black_square_button.png differ diff --git a/taiga/base/static/img/emojis/blossom.png b/taiga/base/static/img/emojis/blossom.png new file mode 100644 index 000000000..ac1d97530 Binary files /dev/null and b/taiga/base/static/img/emojis/blossom.png differ diff --git a/taiga/base/static/img/emojis/blowfish.png b/taiga/base/static/img/emojis/blowfish.png new file mode 100644 index 000000000..8a7f7f112 Binary files /dev/null and b/taiga/base/static/img/emojis/blowfish.png differ diff --git a/taiga/base/static/img/emojis/blue_book.png b/taiga/base/static/img/emojis/blue_book.png new file mode 100644 index 000000000..6c2452a1d Binary files /dev/null and b/taiga/base/static/img/emojis/blue_book.png differ diff --git a/taiga/base/static/img/emojis/blue_car.png b/taiga/base/static/img/emojis/blue_car.png new file mode 100644 index 000000000..71229ce18 Binary files /dev/null and b/taiga/base/static/img/emojis/blue_car.png differ diff --git a/taiga/base/static/img/emojis/blue_heart.png b/taiga/base/static/img/emojis/blue_heart.png new file mode 100644 index 000000000..1a9bef4a7 Binary files /dev/null and b/taiga/base/static/img/emojis/blue_heart.png differ diff --git a/taiga/base/static/img/emojis/blush.png b/taiga/base/static/img/emojis/blush.png new file mode 100644 index 000000000..9674f99ba Binary files /dev/null and b/taiga/base/static/img/emojis/blush.png differ diff --git a/taiga/base/static/img/emojis/boar.png b/taiga/base/static/img/emojis/boar.png new file mode 100644 index 000000000..b9a0c4792 Binary files /dev/null and b/taiga/base/static/img/emojis/boar.png differ diff --git a/taiga/base/static/img/emojis/boat.png b/taiga/base/static/img/emojis/boat.png new file mode 100644 index 000000000..ab65ccc40 Binary files /dev/null and b/taiga/base/static/img/emojis/boat.png differ diff --git a/taiga/base/static/img/emojis/bomb.png b/taiga/base/static/img/emojis/bomb.png new file mode 100644 index 000000000..1302680da Binary files /dev/null and b/taiga/base/static/img/emojis/bomb.png differ diff --git a/taiga/base/static/img/emojis/book.png b/taiga/base/static/img/emojis/book.png new file mode 100644 index 000000000..76b02c65b Binary files /dev/null and b/taiga/base/static/img/emojis/book.png differ diff --git a/taiga/base/static/img/emojis/bookmark.png b/taiga/base/static/img/emojis/bookmark.png new file mode 100644 index 000000000..a313ff196 Binary files /dev/null and b/taiga/base/static/img/emojis/bookmark.png differ diff --git a/taiga/base/static/img/emojis/bookmark_tabs.png b/taiga/base/static/img/emojis/bookmark_tabs.png new file mode 100644 index 000000000..c29b4bd97 Binary files /dev/null and b/taiga/base/static/img/emojis/bookmark_tabs.png differ diff --git a/taiga/base/static/img/emojis/books.png b/taiga/base/static/img/emojis/books.png new file mode 100644 index 000000000..c99644842 Binary files /dev/null and b/taiga/base/static/img/emojis/books.png differ diff --git a/taiga/base/static/img/emojis/boom.png b/taiga/base/static/img/emojis/boom.png new file mode 100644 index 000000000..c73382581 Binary files /dev/null and b/taiga/base/static/img/emojis/boom.png differ diff --git a/taiga/base/static/img/emojis/boot.png b/taiga/base/static/img/emojis/boot.png new file mode 100644 index 000000000..9bad9f14e Binary files /dev/null and b/taiga/base/static/img/emojis/boot.png differ diff --git a/taiga/base/static/img/emojis/bouquet.png b/taiga/base/static/img/emojis/bouquet.png new file mode 100644 index 000000000..9339bbf3b Binary files /dev/null and b/taiga/base/static/img/emojis/bouquet.png differ diff --git a/taiga/base/static/img/emojis/bow.png b/taiga/base/static/img/emojis/bow.png new file mode 100644 index 000000000..3fe31af8f Binary files /dev/null and b/taiga/base/static/img/emojis/bow.png differ diff --git a/taiga/base/static/img/emojis/bowling.png b/taiga/base/static/img/emojis/bowling.png new file mode 100644 index 000000000..93a867f33 Binary files /dev/null and b/taiga/base/static/img/emojis/bowling.png differ diff --git a/taiga/base/static/img/emojis/bowtie.png b/taiga/base/static/img/emojis/bowtie.png new file mode 100644 index 000000000..cbe139f2b Binary files /dev/null and b/taiga/base/static/img/emojis/bowtie.png differ diff --git a/taiga/base/static/img/emojis/boy.png b/taiga/base/static/img/emojis/boy.png new file mode 100644 index 000000000..f9c041d68 Binary files /dev/null and b/taiga/base/static/img/emojis/boy.png differ diff --git a/taiga/base/static/img/emojis/bread.png b/taiga/base/static/img/emojis/bread.png new file mode 100644 index 000000000..29c5dbeb0 Binary files /dev/null and b/taiga/base/static/img/emojis/bread.png differ diff --git a/taiga/base/static/img/emojis/bride_with_veil.png b/taiga/base/static/img/emojis/bride_with_veil.png new file mode 100644 index 000000000..f6739b18f Binary files /dev/null and b/taiga/base/static/img/emojis/bride_with_veil.png differ diff --git a/taiga/base/static/img/emojis/bridge_at_night.png b/taiga/base/static/img/emojis/bridge_at_night.png new file mode 100644 index 000000000..ee47de57b Binary files /dev/null and b/taiga/base/static/img/emojis/bridge_at_night.png differ diff --git a/taiga/base/static/img/emojis/briefcase.png b/taiga/base/static/img/emojis/briefcase.png new file mode 100644 index 000000000..32158b9f6 Binary files /dev/null and b/taiga/base/static/img/emojis/briefcase.png differ diff --git a/taiga/base/static/img/emojis/broken_heart.png b/taiga/base/static/img/emojis/broken_heart.png new file mode 100644 index 000000000..187c5ecf5 Binary files /dev/null and b/taiga/base/static/img/emojis/broken_heart.png differ diff --git a/taiga/base/static/img/emojis/bug.png b/taiga/base/static/img/emojis/bug.png new file mode 100644 index 000000000..47f6b85e1 Binary files /dev/null and b/taiga/base/static/img/emojis/bug.png differ diff --git a/taiga/base/static/img/emojis/bulb.png b/taiga/base/static/img/emojis/bulb.png new file mode 100644 index 000000000..c1238b2dd Binary files /dev/null and b/taiga/base/static/img/emojis/bulb.png differ diff --git a/taiga/base/static/img/emojis/bullettrain_front.png b/taiga/base/static/img/emojis/bullettrain_front.png new file mode 100644 index 000000000..29502e516 Binary files /dev/null and b/taiga/base/static/img/emojis/bullettrain_front.png differ diff --git a/taiga/base/static/img/emojis/bullettrain_side.png b/taiga/base/static/img/emojis/bullettrain_side.png new file mode 100644 index 000000000..ed8d31fab Binary files /dev/null and b/taiga/base/static/img/emojis/bullettrain_side.png differ diff --git a/taiga/base/static/img/emojis/bus.png b/taiga/base/static/img/emojis/bus.png new file mode 100644 index 000000000..208a204fe Binary files /dev/null and b/taiga/base/static/img/emojis/bus.png differ diff --git a/taiga/base/static/img/emojis/busstop.png b/taiga/base/static/img/emojis/busstop.png new file mode 100644 index 000000000..7c0c168cb Binary files /dev/null and b/taiga/base/static/img/emojis/busstop.png differ diff --git a/taiga/base/static/img/emojis/bust_in_silhouette.png b/taiga/base/static/img/emojis/bust_in_silhouette.png new file mode 100644 index 000000000..f02920b2b Binary files /dev/null and b/taiga/base/static/img/emojis/bust_in_silhouette.png differ diff --git a/taiga/base/static/img/emojis/busts_in_silhouette.png b/taiga/base/static/img/emojis/busts_in_silhouette.png new file mode 100644 index 000000000..f11410b6b Binary files /dev/null and b/taiga/base/static/img/emojis/busts_in_silhouette.png differ diff --git a/taiga/base/static/img/emojis/cactus.png b/taiga/base/static/img/emojis/cactus.png new file mode 100644 index 000000000..c0ebf50d6 Binary files /dev/null and b/taiga/base/static/img/emojis/cactus.png differ diff --git a/taiga/base/static/img/emojis/cake.png b/taiga/base/static/img/emojis/cake.png new file mode 100644 index 000000000..288b7ffb1 Binary files /dev/null and b/taiga/base/static/img/emojis/cake.png differ diff --git a/taiga/base/static/img/emojis/calendar.png b/taiga/base/static/img/emojis/calendar.png new file mode 100644 index 000000000..e086c41ba Binary files /dev/null and b/taiga/base/static/img/emojis/calendar.png differ diff --git a/taiga/base/static/img/emojis/calling.png b/taiga/base/static/img/emojis/calling.png new file mode 100644 index 000000000..d2ee43c0d Binary files /dev/null and b/taiga/base/static/img/emojis/calling.png differ diff --git a/taiga/base/static/img/emojis/camel.png b/taiga/base/static/img/emojis/camel.png new file mode 100644 index 000000000..e674783f1 Binary files /dev/null and b/taiga/base/static/img/emojis/camel.png differ diff --git a/taiga/base/static/img/emojis/camera.png b/taiga/base/static/img/emojis/camera.png new file mode 100644 index 000000000..211d6e6d8 Binary files /dev/null and b/taiga/base/static/img/emojis/camera.png differ diff --git a/taiga/base/static/img/emojis/cancer.png b/taiga/base/static/img/emojis/cancer.png new file mode 100644 index 000000000..820a8bc98 Binary files /dev/null and b/taiga/base/static/img/emojis/cancer.png differ diff --git a/taiga/base/static/img/emojis/candy.png b/taiga/base/static/img/emojis/candy.png new file mode 100644 index 000000000..a0d132a04 Binary files /dev/null and b/taiga/base/static/img/emojis/candy.png differ diff --git a/taiga/base/static/img/emojis/capital_abcd.png b/taiga/base/static/img/emojis/capital_abcd.png new file mode 100644 index 000000000..5b5a0485c Binary files /dev/null and b/taiga/base/static/img/emojis/capital_abcd.png differ diff --git a/taiga/base/static/img/emojis/capricorn.png b/taiga/base/static/img/emojis/capricorn.png new file mode 100644 index 000000000..e6f661577 Binary files /dev/null and b/taiga/base/static/img/emojis/capricorn.png differ diff --git a/taiga/base/static/img/emojis/car.png b/taiga/base/static/img/emojis/car.png new file mode 100644 index 000000000..31393f7fe Binary files /dev/null and b/taiga/base/static/img/emojis/car.png differ diff --git a/taiga/base/static/img/emojis/card_index.png b/taiga/base/static/img/emojis/card_index.png new file mode 100644 index 000000000..697546ec1 Binary files /dev/null and b/taiga/base/static/img/emojis/card_index.png differ diff --git a/taiga/base/static/img/emojis/carousel_horse.png b/taiga/base/static/img/emojis/carousel_horse.png new file mode 100644 index 000000000..af960545c Binary files /dev/null and b/taiga/base/static/img/emojis/carousel_horse.png differ diff --git a/taiga/base/static/img/emojis/cat.png b/taiga/base/static/img/emojis/cat.png new file mode 100644 index 000000000..c8307fd00 Binary files /dev/null and b/taiga/base/static/img/emojis/cat.png differ diff --git a/taiga/base/static/img/emojis/cat2.png b/taiga/base/static/img/emojis/cat2.png new file mode 100644 index 000000000..090137ed9 Binary files /dev/null and b/taiga/base/static/img/emojis/cat2.png differ diff --git a/taiga/base/static/img/emojis/cd.png b/taiga/base/static/img/emojis/cd.png new file mode 100644 index 000000000..c2d5f1470 Binary files /dev/null and b/taiga/base/static/img/emojis/cd.png differ diff --git a/taiga/base/static/img/emojis/chart.png b/taiga/base/static/img/emojis/chart.png new file mode 100644 index 000000000..aa527cad4 Binary files /dev/null and b/taiga/base/static/img/emojis/chart.png differ diff --git a/taiga/base/static/img/emojis/chart_with_downwards_trend.png b/taiga/base/static/img/emojis/chart_with_downwards_trend.png new file mode 100644 index 000000000..50fb4b1e0 Binary files /dev/null and b/taiga/base/static/img/emojis/chart_with_downwards_trend.png differ diff --git a/taiga/base/static/img/emojis/chart_with_upwards_trend.png b/taiga/base/static/img/emojis/chart_with_upwards_trend.png new file mode 100644 index 000000000..364e792f5 Binary files /dev/null and b/taiga/base/static/img/emojis/chart_with_upwards_trend.png differ diff --git a/taiga/base/static/img/emojis/checkered_flag.png b/taiga/base/static/img/emojis/checkered_flag.png new file mode 100644 index 000000000..4a4f652f1 Binary files /dev/null and b/taiga/base/static/img/emojis/checkered_flag.png differ diff --git a/taiga/base/static/img/emojis/cherries.png b/taiga/base/static/img/emojis/cherries.png new file mode 100644 index 000000000..49aa7d628 Binary files /dev/null and b/taiga/base/static/img/emojis/cherries.png differ diff --git a/taiga/base/static/img/emojis/cherry_blossom.png b/taiga/base/static/img/emojis/cherry_blossom.png new file mode 100644 index 000000000..e996cd45c Binary files /dev/null and b/taiga/base/static/img/emojis/cherry_blossom.png differ diff --git a/taiga/base/static/img/emojis/chestnut.png b/taiga/base/static/img/emojis/chestnut.png new file mode 100644 index 000000000..451e2ccbc Binary files /dev/null and b/taiga/base/static/img/emojis/chestnut.png differ diff --git a/taiga/base/static/img/emojis/chicken.png b/taiga/base/static/img/emojis/chicken.png new file mode 100644 index 000000000..37bd0f74c Binary files /dev/null and b/taiga/base/static/img/emojis/chicken.png differ diff --git a/taiga/base/static/img/emojis/children_crossing.png b/taiga/base/static/img/emojis/children_crossing.png new file mode 100644 index 000000000..62422ae95 Binary files /dev/null and b/taiga/base/static/img/emojis/children_crossing.png differ diff --git a/taiga/base/static/img/emojis/chocolate_bar.png b/taiga/base/static/img/emojis/chocolate_bar.png new file mode 100644 index 000000000..f7a59bf7d Binary files /dev/null and b/taiga/base/static/img/emojis/chocolate_bar.png differ diff --git a/taiga/base/static/img/emojis/christmas_tree.png b/taiga/base/static/img/emojis/christmas_tree.png new file mode 100644 index 000000000..7ff7b2a3f Binary files /dev/null and b/taiga/base/static/img/emojis/christmas_tree.png differ diff --git a/taiga/base/static/img/emojis/church.png b/taiga/base/static/img/emojis/church.png new file mode 100644 index 000000000..cb16eba65 Binary files /dev/null and b/taiga/base/static/img/emojis/church.png differ diff --git a/taiga/base/static/img/emojis/cinema.png b/taiga/base/static/img/emojis/cinema.png new file mode 100644 index 000000000..e80c1f00e Binary files /dev/null and b/taiga/base/static/img/emojis/cinema.png differ diff --git a/taiga/base/static/img/emojis/circus_tent.png b/taiga/base/static/img/emojis/circus_tent.png new file mode 100644 index 000000000..12a57bfb9 Binary files /dev/null and b/taiga/base/static/img/emojis/circus_tent.png differ diff --git a/taiga/base/static/img/emojis/city_sunrise.png b/taiga/base/static/img/emojis/city_sunrise.png new file mode 100644 index 000000000..42b64d088 Binary files /dev/null and b/taiga/base/static/img/emojis/city_sunrise.png differ diff --git a/taiga/base/static/img/emojis/city_sunset.png b/taiga/base/static/img/emojis/city_sunset.png new file mode 100644 index 000000000..f262d7cf0 Binary files /dev/null and b/taiga/base/static/img/emojis/city_sunset.png differ diff --git a/taiga/base/static/img/emojis/cl.png b/taiga/base/static/img/emojis/cl.png new file mode 100644 index 000000000..32c9488b2 Binary files /dev/null and b/taiga/base/static/img/emojis/cl.png differ diff --git a/taiga/base/static/img/emojis/clap.png b/taiga/base/static/img/emojis/clap.png new file mode 100644 index 000000000..1156223c1 Binary files /dev/null and b/taiga/base/static/img/emojis/clap.png differ diff --git a/taiga/base/static/img/emojis/clapper.png b/taiga/base/static/img/emojis/clapper.png new file mode 100644 index 000000000..62ce35e86 Binary files /dev/null and b/taiga/base/static/img/emojis/clapper.png differ diff --git a/taiga/base/static/img/emojis/clipboard.png b/taiga/base/static/img/emojis/clipboard.png new file mode 100644 index 000000000..2cbe981bf Binary files /dev/null and b/taiga/base/static/img/emojis/clipboard.png differ diff --git a/taiga/base/static/img/emojis/clock1.png b/taiga/base/static/img/emojis/clock1.png new file mode 100644 index 000000000..be673dc1c Binary files /dev/null and b/taiga/base/static/img/emojis/clock1.png differ diff --git a/taiga/base/static/img/emojis/clock10.png b/taiga/base/static/img/emojis/clock10.png new file mode 100644 index 000000000..5d368878b Binary files /dev/null and b/taiga/base/static/img/emojis/clock10.png differ diff --git a/taiga/base/static/img/emojis/clock1030.png b/taiga/base/static/img/emojis/clock1030.png new file mode 100644 index 000000000..a3ed02a15 Binary files /dev/null and b/taiga/base/static/img/emojis/clock1030.png differ diff --git a/taiga/base/static/img/emojis/clock11.png b/taiga/base/static/img/emojis/clock11.png new file mode 100644 index 000000000..4d1ec5d59 Binary files /dev/null and b/taiga/base/static/img/emojis/clock11.png differ diff --git a/taiga/base/static/img/emojis/clock1130.png b/taiga/base/static/img/emojis/clock1130.png new file mode 100644 index 000000000..152623754 Binary files /dev/null and b/taiga/base/static/img/emojis/clock1130.png differ diff --git a/taiga/base/static/img/emojis/clock12.png b/taiga/base/static/img/emojis/clock12.png new file mode 100644 index 000000000..9feeff307 Binary files /dev/null and b/taiga/base/static/img/emojis/clock12.png differ diff --git a/taiga/base/static/img/emojis/clock1230.png b/taiga/base/static/img/emojis/clock1230.png new file mode 100644 index 000000000..41b453715 Binary files /dev/null and b/taiga/base/static/img/emojis/clock1230.png differ diff --git a/taiga/base/static/img/emojis/clock130.png b/taiga/base/static/img/emojis/clock130.png new file mode 100644 index 000000000..aec7a6458 Binary files /dev/null and b/taiga/base/static/img/emojis/clock130.png differ diff --git a/taiga/base/static/img/emojis/clock2.png b/taiga/base/static/img/emojis/clock2.png new file mode 100644 index 000000000..7d96e8556 Binary files /dev/null and b/taiga/base/static/img/emojis/clock2.png differ diff --git a/taiga/base/static/img/emojis/clock230.png b/taiga/base/static/img/emojis/clock230.png new file mode 100644 index 000000000..9c9208e85 Binary files /dev/null and b/taiga/base/static/img/emojis/clock230.png differ diff --git a/taiga/base/static/img/emojis/clock3.png b/taiga/base/static/img/emojis/clock3.png new file mode 100644 index 000000000..d33427581 Binary files /dev/null and b/taiga/base/static/img/emojis/clock3.png differ diff --git a/taiga/base/static/img/emojis/clock330.png b/taiga/base/static/img/emojis/clock330.png new file mode 100644 index 000000000..b37185dfb Binary files /dev/null and b/taiga/base/static/img/emojis/clock330.png differ diff --git a/taiga/base/static/img/emojis/clock4.png b/taiga/base/static/img/emojis/clock4.png new file mode 100644 index 000000000..a2991ada1 Binary files /dev/null and b/taiga/base/static/img/emojis/clock4.png differ diff --git a/taiga/base/static/img/emojis/clock430.png b/taiga/base/static/img/emojis/clock430.png new file mode 100644 index 000000000..e3d1ff51a Binary files /dev/null and b/taiga/base/static/img/emojis/clock430.png differ diff --git a/taiga/base/static/img/emojis/clock5.png b/taiga/base/static/img/emojis/clock5.png new file mode 100644 index 000000000..de728e23d Binary files /dev/null and b/taiga/base/static/img/emojis/clock5.png differ diff --git a/taiga/base/static/img/emojis/clock530.png b/taiga/base/static/img/emojis/clock530.png new file mode 100644 index 000000000..54e4555ee Binary files /dev/null and b/taiga/base/static/img/emojis/clock530.png differ diff --git a/taiga/base/static/img/emojis/clock6.png b/taiga/base/static/img/emojis/clock6.png new file mode 100644 index 000000000..38b509e50 Binary files /dev/null and b/taiga/base/static/img/emojis/clock6.png differ diff --git a/taiga/base/static/img/emojis/clock630.png b/taiga/base/static/img/emojis/clock630.png new file mode 100644 index 000000000..f0c311d9c Binary files /dev/null and b/taiga/base/static/img/emojis/clock630.png differ diff --git a/taiga/base/static/img/emojis/clock7.png b/taiga/base/static/img/emojis/clock7.png new file mode 100644 index 000000000..852a73d50 Binary files /dev/null and b/taiga/base/static/img/emojis/clock7.png differ diff --git a/taiga/base/static/img/emojis/clock730.png b/taiga/base/static/img/emojis/clock730.png new file mode 100644 index 000000000..03e30c251 Binary files /dev/null and b/taiga/base/static/img/emojis/clock730.png differ diff --git a/taiga/base/static/img/emojis/clock8.png b/taiga/base/static/img/emojis/clock8.png new file mode 100644 index 000000000..a47559ca8 Binary files /dev/null and b/taiga/base/static/img/emojis/clock8.png differ diff --git a/taiga/base/static/img/emojis/clock830.png b/taiga/base/static/img/emojis/clock830.png new file mode 100644 index 000000000..b076a5a19 Binary files /dev/null and b/taiga/base/static/img/emojis/clock830.png differ diff --git a/taiga/base/static/img/emojis/clock9.png b/taiga/base/static/img/emojis/clock9.png new file mode 100644 index 000000000..f3003c80d Binary files /dev/null and b/taiga/base/static/img/emojis/clock9.png differ diff --git a/taiga/base/static/img/emojis/clock930.png b/taiga/base/static/img/emojis/clock930.png new file mode 100644 index 000000000..77d560021 Binary files /dev/null and b/taiga/base/static/img/emojis/clock930.png differ diff --git a/taiga/base/static/img/emojis/closed_book.png b/taiga/base/static/img/emojis/closed_book.png new file mode 100644 index 000000000..9e0a9ae22 Binary files /dev/null and b/taiga/base/static/img/emojis/closed_book.png differ diff --git a/taiga/base/static/img/emojis/closed_lock_with_key.png b/taiga/base/static/img/emojis/closed_lock_with_key.png new file mode 100644 index 000000000..009595343 Binary files /dev/null and b/taiga/base/static/img/emojis/closed_lock_with_key.png differ diff --git a/taiga/base/static/img/emojis/closed_umbrella.png b/taiga/base/static/img/emojis/closed_umbrella.png new file mode 100644 index 000000000..314e4853a Binary files /dev/null and b/taiga/base/static/img/emojis/closed_umbrella.png differ diff --git a/taiga/base/static/img/emojis/cloud.png b/taiga/base/static/img/emojis/cloud.png new file mode 100644 index 000000000..5a77db269 Binary files /dev/null and b/taiga/base/static/img/emojis/cloud.png differ diff --git a/taiga/base/static/img/emojis/clubs.png b/taiga/base/static/img/emojis/clubs.png new file mode 100644 index 000000000..88dd644fc Binary files /dev/null and b/taiga/base/static/img/emojis/clubs.png differ diff --git a/taiga/base/static/img/emojis/cn.png b/taiga/base/static/img/emojis/cn.png new file mode 100644 index 000000000..941702d61 Binary files /dev/null and b/taiga/base/static/img/emojis/cn.png differ diff --git a/taiga/base/static/img/emojis/cocktail.png b/taiga/base/static/img/emojis/cocktail.png new file mode 100644 index 000000000..2afdef2c4 Binary files /dev/null and b/taiga/base/static/img/emojis/cocktail.png differ diff --git a/taiga/base/static/img/emojis/coffee.png b/taiga/base/static/img/emojis/coffee.png new file mode 100644 index 000000000..3d6e3f394 Binary files /dev/null and b/taiga/base/static/img/emojis/coffee.png differ diff --git a/taiga/base/static/img/emojis/cold_sweat.png b/taiga/base/static/img/emojis/cold_sweat.png new file mode 100644 index 000000000..7b0223c6f Binary files /dev/null and b/taiga/base/static/img/emojis/cold_sweat.png differ diff --git a/taiga/base/static/img/emojis/collision.png b/taiga/base/static/img/emojis/collision.png new file mode 100644 index 000000000..c73382581 Binary files /dev/null and b/taiga/base/static/img/emojis/collision.png differ diff --git a/taiga/base/static/img/emojis/computer.png b/taiga/base/static/img/emojis/computer.png new file mode 100644 index 000000000..4ac2f8f68 Binary files /dev/null and b/taiga/base/static/img/emojis/computer.png differ diff --git a/taiga/base/static/img/emojis/confetti_ball.png b/taiga/base/static/img/emojis/confetti_ball.png new file mode 100644 index 000000000..b89a0314c Binary files /dev/null and b/taiga/base/static/img/emojis/confetti_ball.png differ diff --git a/taiga/base/static/img/emojis/confounded.png b/taiga/base/static/img/emojis/confounded.png new file mode 100644 index 000000000..6036df791 Binary files /dev/null and b/taiga/base/static/img/emojis/confounded.png differ diff --git a/taiga/base/static/img/emojis/confused.png b/taiga/base/static/img/emojis/confused.png new file mode 100644 index 000000000..6350ffca9 Binary files /dev/null and b/taiga/base/static/img/emojis/confused.png differ diff --git a/taiga/base/static/img/emojis/congratulations.png b/taiga/base/static/img/emojis/congratulations.png new file mode 100644 index 000000000..42f78080e Binary files /dev/null and b/taiga/base/static/img/emojis/congratulations.png differ diff --git a/taiga/base/static/img/emojis/construction.png b/taiga/base/static/img/emojis/construction.png new file mode 100644 index 000000000..5661e9660 Binary files /dev/null and b/taiga/base/static/img/emojis/construction.png differ diff --git a/taiga/base/static/img/emojis/construction_worker.png b/taiga/base/static/img/emojis/construction_worker.png new file mode 100644 index 000000000..bb3f0f8f6 Binary files /dev/null and b/taiga/base/static/img/emojis/construction_worker.png differ diff --git a/taiga/base/static/img/emojis/convenience_store.png b/taiga/base/static/img/emojis/convenience_store.png new file mode 100644 index 000000000..54812389b Binary files /dev/null and b/taiga/base/static/img/emojis/convenience_store.png differ diff --git a/taiga/base/static/img/emojis/cookie.png b/taiga/base/static/img/emojis/cookie.png new file mode 100644 index 000000000..e0051c90d Binary files /dev/null and b/taiga/base/static/img/emojis/cookie.png differ diff --git a/taiga/base/static/img/emojis/cool.png b/taiga/base/static/img/emojis/cool.png new file mode 100644 index 000000000..30452ecf9 Binary files /dev/null and b/taiga/base/static/img/emojis/cool.png differ diff --git a/taiga/base/static/img/emojis/cop.png b/taiga/base/static/img/emojis/cop.png new file mode 100644 index 000000000..a6d1f25b6 Binary files /dev/null and b/taiga/base/static/img/emojis/cop.png differ diff --git a/taiga/base/static/img/emojis/copyright.png b/taiga/base/static/img/emojis/copyright.png new file mode 100644 index 000000000..5fdd5ecec Binary files /dev/null and b/taiga/base/static/img/emojis/copyright.png differ diff --git a/taiga/base/static/img/emojis/corn.png b/taiga/base/static/img/emojis/corn.png new file mode 100644 index 000000000..69c1c4071 Binary files /dev/null and b/taiga/base/static/img/emojis/corn.png differ diff --git a/taiga/base/static/img/emojis/couple.png b/taiga/base/static/img/emojis/couple.png new file mode 100644 index 000000000..6bd22390b Binary files /dev/null and b/taiga/base/static/img/emojis/couple.png differ diff --git a/taiga/base/static/img/emojis/couple_with_heart.png b/taiga/base/static/img/emojis/couple_with_heart.png new file mode 100644 index 000000000..dc5090b9a Binary files /dev/null and b/taiga/base/static/img/emojis/couple_with_heart.png differ diff --git a/taiga/base/static/img/emojis/couplekiss.png b/taiga/base/static/img/emojis/couplekiss.png new file mode 100644 index 000000000..6e04c6bc5 Binary files /dev/null and b/taiga/base/static/img/emojis/couplekiss.png differ diff --git a/taiga/base/static/img/emojis/cow.png b/taiga/base/static/img/emojis/cow.png new file mode 100644 index 000000000..9dd0fd6c6 Binary files /dev/null and b/taiga/base/static/img/emojis/cow.png differ diff --git a/taiga/base/static/img/emojis/cow2.png b/taiga/base/static/img/emojis/cow2.png new file mode 100644 index 000000000..ad246241c Binary files /dev/null and b/taiga/base/static/img/emojis/cow2.png differ diff --git a/taiga/base/static/img/emojis/credit_card.png b/taiga/base/static/img/emojis/credit_card.png new file mode 100644 index 000000000..d3da16656 Binary files /dev/null and b/taiga/base/static/img/emojis/credit_card.png differ diff --git a/taiga/base/static/img/emojis/crocodile.png b/taiga/base/static/img/emojis/crocodile.png new file mode 100644 index 000000000..116395c48 Binary files /dev/null and b/taiga/base/static/img/emojis/crocodile.png differ diff --git a/taiga/base/static/img/emojis/crossed_flags.png b/taiga/base/static/img/emojis/crossed_flags.png new file mode 100644 index 000000000..aacfd9b16 Binary files /dev/null and b/taiga/base/static/img/emojis/crossed_flags.png differ diff --git a/taiga/base/static/img/emojis/crown.png b/taiga/base/static/img/emojis/crown.png new file mode 100644 index 000000000..e86a163a9 Binary files /dev/null and b/taiga/base/static/img/emojis/crown.png differ diff --git a/taiga/base/static/img/emojis/cry.png b/taiga/base/static/img/emojis/cry.png new file mode 100644 index 000000000..be8eaad61 Binary files /dev/null and b/taiga/base/static/img/emojis/cry.png differ diff --git a/taiga/base/static/img/emojis/crying_cat_face.png b/taiga/base/static/img/emojis/crying_cat_face.png new file mode 100644 index 000000000..ecc415896 Binary files /dev/null and b/taiga/base/static/img/emojis/crying_cat_face.png differ diff --git a/taiga/base/static/img/emojis/crystal_ball.png b/taiga/base/static/img/emojis/crystal_ball.png new file mode 100644 index 000000000..dba2bfebd Binary files /dev/null and b/taiga/base/static/img/emojis/crystal_ball.png differ diff --git a/taiga/base/static/img/emojis/cupid.png b/taiga/base/static/img/emojis/cupid.png new file mode 100644 index 000000000..f4591ef09 Binary files /dev/null and b/taiga/base/static/img/emojis/cupid.png differ diff --git a/taiga/base/static/img/emojis/curly_loop.png b/taiga/base/static/img/emojis/curly_loop.png new file mode 100644 index 000000000..f6563a116 Binary files /dev/null and b/taiga/base/static/img/emojis/curly_loop.png differ diff --git a/taiga/base/static/img/emojis/currency_exchange.png b/taiga/base/static/img/emojis/currency_exchange.png new file mode 100644 index 000000000..c165bc648 Binary files /dev/null and b/taiga/base/static/img/emojis/currency_exchange.png differ diff --git a/taiga/base/static/img/emojis/curry.png b/taiga/base/static/img/emojis/curry.png new file mode 100644 index 000000000..2a8d2cf35 Binary files /dev/null and b/taiga/base/static/img/emojis/curry.png differ diff --git a/taiga/base/static/img/emojis/custard.png b/taiga/base/static/img/emojis/custard.png new file mode 100644 index 000000000..b6f4023cc Binary files /dev/null and b/taiga/base/static/img/emojis/custard.png differ diff --git a/taiga/base/static/img/emojis/customs.png b/taiga/base/static/img/emojis/customs.png new file mode 100644 index 000000000..33f975e86 Binary files /dev/null and b/taiga/base/static/img/emojis/customs.png differ diff --git a/taiga/base/static/img/emojis/cyclone.png b/taiga/base/static/img/emojis/cyclone.png new file mode 100644 index 000000000..bc33f5b39 Binary files /dev/null and b/taiga/base/static/img/emojis/cyclone.png differ diff --git a/taiga/base/static/img/emojis/dancer.png b/taiga/base/static/img/emojis/dancer.png new file mode 100644 index 000000000..945aed19f Binary files /dev/null and b/taiga/base/static/img/emojis/dancer.png differ diff --git a/taiga/base/static/img/emojis/dancers.png b/taiga/base/static/img/emojis/dancers.png new file mode 100644 index 000000000..9b78bef33 Binary files /dev/null and b/taiga/base/static/img/emojis/dancers.png differ diff --git a/taiga/base/static/img/emojis/dango.png b/taiga/base/static/img/emojis/dango.png new file mode 100644 index 000000000..01a9d64cb Binary files /dev/null and b/taiga/base/static/img/emojis/dango.png differ diff --git a/taiga/base/static/img/emojis/dart.png b/taiga/base/static/img/emojis/dart.png new file mode 100644 index 000000000..2fb58d9fa Binary files /dev/null and b/taiga/base/static/img/emojis/dart.png differ diff --git a/taiga/base/static/img/emojis/dash.png b/taiga/base/static/img/emojis/dash.png new file mode 100644 index 000000000..4abae3cb9 Binary files /dev/null and b/taiga/base/static/img/emojis/dash.png differ diff --git a/taiga/base/static/img/emojis/date.png b/taiga/base/static/img/emojis/date.png new file mode 100644 index 000000000..e607aa750 Binary files /dev/null and b/taiga/base/static/img/emojis/date.png differ diff --git a/taiga/base/static/img/emojis/de.png b/taiga/base/static/img/emojis/de.png new file mode 100644 index 000000000..a0e941499 Binary files /dev/null and b/taiga/base/static/img/emojis/de.png differ diff --git a/taiga/base/static/img/emojis/deciduous_tree.png b/taiga/base/static/img/emojis/deciduous_tree.png new file mode 100644 index 000000000..46eb81ef0 Binary files /dev/null and b/taiga/base/static/img/emojis/deciduous_tree.png differ diff --git a/taiga/base/static/img/emojis/department_store.png b/taiga/base/static/img/emojis/department_store.png new file mode 100644 index 000000000..168e5f8dd Binary files /dev/null and b/taiga/base/static/img/emojis/department_store.png differ diff --git a/taiga/base/static/img/emojis/diamond_shape_with_a_dot_inside.png b/taiga/base/static/img/emojis/diamond_shape_with_a_dot_inside.png new file mode 100644 index 000000000..1ad11be00 Binary files /dev/null and b/taiga/base/static/img/emojis/diamond_shape_with_a_dot_inside.png differ diff --git a/taiga/base/static/img/emojis/diamonds.png b/taiga/base/static/img/emojis/diamonds.png new file mode 100644 index 000000000..cee6d170f Binary files /dev/null and b/taiga/base/static/img/emojis/diamonds.png differ diff --git a/taiga/base/static/img/emojis/disappointed.png b/taiga/base/static/img/emojis/disappointed.png new file mode 100644 index 000000000..5c8500cfd Binary files /dev/null and b/taiga/base/static/img/emojis/disappointed.png differ diff --git a/taiga/base/static/img/emojis/disappointed_relieved.png b/taiga/base/static/img/emojis/disappointed_relieved.png new file mode 100644 index 000000000..91b275139 Binary files /dev/null and b/taiga/base/static/img/emojis/disappointed_relieved.png differ diff --git a/taiga/base/static/img/emojis/dizzy.png b/taiga/base/static/img/emojis/dizzy.png new file mode 100644 index 000000000..7acc9e120 Binary files /dev/null and b/taiga/base/static/img/emojis/dizzy.png differ diff --git a/taiga/base/static/img/emojis/dizzy_face.png b/taiga/base/static/img/emojis/dizzy_face.png new file mode 100644 index 000000000..ee6174085 Binary files /dev/null and b/taiga/base/static/img/emojis/dizzy_face.png differ diff --git a/taiga/base/static/img/emojis/do_not_litter.png b/taiga/base/static/img/emojis/do_not_litter.png new file mode 100644 index 000000000..b0c4b5ab3 Binary files /dev/null and b/taiga/base/static/img/emojis/do_not_litter.png differ diff --git a/taiga/base/static/img/emojis/dog.png b/taiga/base/static/img/emojis/dog.png new file mode 100644 index 000000000..62072a794 Binary files /dev/null and b/taiga/base/static/img/emojis/dog.png differ diff --git a/taiga/base/static/img/emojis/dog2.png b/taiga/base/static/img/emojis/dog2.png new file mode 100644 index 000000000..f8f1fda30 Binary files /dev/null and b/taiga/base/static/img/emojis/dog2.png differ diff --git a/taiga/base/static/img/emojis/dollar.png b/taiga/base/static/img/emojis/dollar.png new file mode 100644 index 000000000..49b21d3ba Binary files /dev/null and b/taiga/base/static/img/emojis/dollar.png differ diff --git a/taiga/base/static/img/emojis/dolls.png b/taiga/base/static/img/emojis/dolls.png new file mode 100644 index 000000000..d6489d48c Binary files /dev/null and b/taiga/base/static/img/emojis/dolls.png differ diff --git a/taiga/base/static/img/emojis/dolphin.png b/taiga/base/static/img/emojis/dolphin.png new file mode 100644 index 000000000..5bd2543cd Binary files /dev/null and b/taiga/base/static/img/emojis/dolphin.png differ diff --git a/taiga/base/static/img/emojis/donut.png b/taiga/base/static/img/emojis/donut.png new file mode 100644 index 000000000..aa6653e3b Binary files /dev/null and b/taiga/base/static/img/emojis/donut.png differ diff --git a/taiga/base/static/img/emojis/door.png b/taiga/base/static/img/emojis/door.png new file mode 100644 index 000000000..7a7b9ae74 Binary files /dev/null and b/taiga/base/static/img/emojis/door.png differ diff --git a/taiga/base/static/img/emojis/doughnut.png b/taiga/base/static/img/emojis/doughnut.png new file mode 100644 index 000000000..aa6653e3b Binary files /dev/null and b/taiga/base/static/img/emojis/doughnut.png differ diff --git a/taiga/base/static/img/emojis/dragon.png b/taiga/base/static/img/emojis/dragon.png new file mode 100644 index 000000000..c563d88dd Binary files /dev/null and b/taiga/base/static/img/emojis/dragon.png differ diff --git a/taiga/base/static/img/emojis/dragon_face.png b/taiga/base/static/img/emojis/dragon_face.png new file mode 100644 index 000000000..21cc40d76 Binary files /dev/null and b/taiga/base/static/img/emojis/dragon_face.png differ diff --git a/taiga/base/static/img/emojis/dress.png b/taiga/base/static/img/emojis/dress.png new file mode 100644 index 000000000..08c74f542 Binary files /dev/null and b/taiga/base/static/img/emojis/dress.png differ diff --git a/taiga/base/static/img/emojis/dromedary_camel.png b/taiga/base/static/img/emojis/dromedary_camel.png new file mode 100644 index 000000000..eeb50b01a Binary files /dev/null and b/taiga/base/static/img/emojis/dromedary_camel.png differ diff --git a/taiga/base/static/img/emojis/droplet.png b/taiga/base/static/img/emojis/droplet.png new file mode 100644 index 000000000..353265bc1 Binary files /dev/null and b/taiga/base/static/img/emojis/droplet.png differ diff --git a/taiga/base/static/img/emojis/dvd.png b/taiga/base/static/img/emojis/dvd.png new file mode 100644 index 000000000..06127724d Binary files /dev/null and b/taiga/base/static/img/emojis/dvd.png differ diff --git a/taiga/base/static/img/emojis/e-mail.png b/taiga/base/static/img/emojis/e-mail.png new file mode 100644 index 000000000..73c511054 Binary files /dev/null and b/taiga/base/static/img/emojis/e-mail.png differ diff --git a/taiga/base/static/img/emojis/ear.png b/taiga/base/static/img/emojis/ear.png new file mode 100644 index 000000000..12b2123f4 Binary files /dev/null and b/taiga/base/static/img/emojis/ear.png differ diff --git a/taiga/base/static/img/emojis/ear_of_rice.png b/taiga/base/static/img/emojis/ear_of_rice.png new file mode 100644 index 000000000..56185b928 Binary files /dev/null and b/taiga/base/static/img/emojis/ear_of_rice.png differ diff --git a/taiga/base/static/img/emojis/earth_africa.png b/taiga/base/static/img/emojis/earth_africa.png new file mode 100644 index 000000000..f34e82f77 Binary files /dev/null and b/taiga/base/static/img/emojis/earth_africa.png differ diff --git a/taiga/base/static/img/emojis/earth_americas.png b/taiga/base/static/img/emojis/earth_americas.png new file mode 100644 index 000000000..4abbe0017 Binary files /dev/null and b/taiga/base/static/img/emojis/earth_americas.png differ diff --git a/taiga/base/static/img/emojis/earth_asia.png b/taiga/base/static/img/emojis/earth_asia.png new file mode 100644 index 000000000..5ce048848 Binary files /dev/null and b/taiga/base/static/img/emojis/earth_asia.png differ diff --git a/taiga/base/static/img/emojis/egg.png b/taiga/base/static/img/emojis/egg.png new file mode 100644 index 000000000..4fc816807 Binary files /dev/null and b/taiga/base/static/img/emojis/egg.png differ diff --git a/taiga/base/static/img/emojis/eggplant.png b/taiga/base/static/img/emojis/eggplant.png new file mode 100644 index 000000000..00fc56e3e Binary files /dev/null and b/taiga/base/static/img/emojis/eggplant.png differ diff --git a/taiga/base/static/img/emojis/eight.png b/taiga/base/static/img/emojis/eight.png new file mode 100644 index 000000000..82ee6c4dd Binary files /dev/null and b/taiga/base/static/img/emojis/eight.png differ diff --git a/taiga/base/static/img/emojis/eight_pointed_black_star.png b/taiga/base/static/img/emojis/eight_pointed_black_star.png new file mode 100644 index 000000000..91e7018b1 Binary files /dev/null and b/taiga/base/static/img/emojis/eight_pointed_black_star.png differ diff --git a/taiga/base/static/img/emojis/eight_spoked_asterisk.png b/taiga/base/static/img/emojis/eight_spoked_asterisk.png new file mode 100644 index 000000000..253094993 Binary files /dev/null and b/taiga/base/static/img/emojis/eight_spoked_asterisk.png differ diff --git a/taiga/base/static/img/emojis/electric_plug.png b/taiga/base/static/img/emojis/electric_plug.png new file mode 100644 index 000000000..d9e14e417 Binary files /dev/null and b/taiga/base/static/img/emojis/electric_plug.png differ diff --git a/taiga/base/static/img/emojis/elephant.png b/taiga/base/static/img/emojis/elephant.png new file mode 100644 index 000000000..27cfc5b75 Binary files /dev/null and b/taiga/base/static/img/emojis/elephant.png differ diff --git a/taiga/base/static/img/emojis/email.png b/taiga/base/static/img/emojis/email.png new file mode 100644 index 000000000..1bc0578b7 Binary files /dev/null and b/taiga/base/static/img/emojis/email.png differ diff --git a/taiga/base/static/img/emojis/end.png b/taiga/base/static/img/emojis/end.png new file mode 100644 index 000000000..6319ed358 Binary files /dev/null and b/taiga/base/static/img/emojis/end.png differ diff --git a/taiga/base/static/img/emojis/envelope.png b/taiga/base/static/img/emojis/envelope.png new file mode 100644 index 000000000..f2449687c Binary files /dev/null and b/taiga/base/static/img/emojis/envelope.png differ diff --git a/taiga/base/static/img/emojis/es.png b/taiga/base/static/img/emojis/es.png new file mode 100644 index 000000000..20ef411ea Binary files /dev/null and b/taiga/base/static/img/emojis/es.png differ diff --git a/taiga/base/static/img/emojis/euro.png b/taiga/base/static/img/emojis/euro.png new file mode 100644 index 000000000..94ac9468a Binary files /dev/null and b/taiga/base/static/img/emojis/euro.png differ diff --git a/taiga/base/static/img/emojis/european_castle.png b/taiga/base/static/img/emojis/european_castle.png new file mode 100644 index 000000000..c6f22b05c Binary files /dev/null and b/taiga/base/static/img/emojis/european_castle.png differ diff --git a/taiga/base/static/img/emojis/european_post_office.png b/taiga/base/static/img/emojis/european_post_office.png new file mode 100644 index 000000000..cb9fbb7f8 Binary files /dev/null and b/taiga/base/static/img/emojis/european_post_office.png differ diff --git a/taiga/base/static/img/emojis/evergreen_tree.png b/taiga/base/static/img/emojis/evergreen_tree.png new file mode 100644 index 000000000..efa687fca Binary files /dev/null and b/taiga/base/static/img/emojis/evergreen_tree.png differ diff --git a/taiga/base/static/img/emojis/exclamation.png b/taiga/base/static/img/emojis/exclamation.png new file mode 100644 index 000000000..bca3075f2 Binary files /dev/null and b/taiga/base/static/img/emojis/exclamation.png differ diff --git a/taiga/base/static/img/emojis/expressionless.png b/taiga/base/static/img/emojis/expressionless.png new file mode 100644 index 000000000..88758f018 Binary files /dev/null and b/taiga/base/static/img/emojis/expressionless.png differ diff --git a/taiga/base/static/img/emojis/eyeglasses.png b/taiga/base/static/img/emojis/eyeglasses.png new file mode 100644 index 000000000..115f09417 Binary files /dev/null and b/taiga/base/static/img/emojis/eyeglasses.png differ diff --git a/taiga/base/static/img/emojis/eyes.png b/taiga/base/static/img/emojis/eyes.png new file mode 100644 index 000000000..84d9ff015 Binary files /dev/null and b/taiga/base/static/img/emojis/eyes.png differ diff --git a/taiga/base/static/img/emojis/facepunch.png b/taiga/base/static/img/emojis/facepunch.png new file mode 100644 index 000000000..f817ca3fb Binary files /dev/null and b/taiga/base/static/img/emojis/facepunch.png differ diff --git a/taiga/base/static/img/emojis/factory.png b/taiga/base/static/img/emojis/factory.png new file mode 100644 index 000000000..e7082f077 Binary files /dev/null and b/taiga/base/static/img/emojis/factory.png differ diff --git a/taiga/base/static/img/emojis/fallen_leaf.png b/taiga/base/static/img/emojis/fallen_leaf.png new file mode 100644 index 000000000..ad970b256 Binary files /dev/null and b/taiga/base/static/img/emojis/fallen_leaf.png differ diff --git a/taiga/base/static/img/emojis/family.png b/taiga/base/static/img/emojis/family.png new file mode 100644 index 000000000..41566a660 Binary files /dev/null and b/taiga/base/static/img/emojis/family.png differ diff --git a/taiga/base/static/img/emojis/fast_forward.png b/taiga/base/static/img/emojis/fast_forward.png new file mode 100644 index 000000000..084d8d7c1 Binary files /dev/null and b/taiga/base/static/img/emojis/fast_forward.png differ diff --git a/taiga/base/static/img/emojis/fax.png b/taiga/base/static/img/emojis/fax.png new file mode 100644 index 000000000..cc176a6ba Binary files /dev/null and b/taiga/base/static/img/emojis/fax.png differ diff --git a/taiga/base/static/img/emojis/fearful.png b/taiga/base/static/img/emojis/fearful.png new file mode 100644 index 000000000..15f7301a0 Binary files /dev/null and b/taiga/base/static/img/emojis/fearful.png differ diff --git a/taiga/base/static/img/emojis/feelsgood.png b/taiga/base/static/img/emojis/feelsgood.png new file mode 100644 index 000000000..8910a87a5 Binary files /dev/null and b/taiga/base/static/img/emojis/feelsgood.png differ diff --git a/taiga/base/static/img/emojis/feet.png b/taiga/base/static/img/emojis/feet.png new file mode 100644 index 000000000..8c52f522d Binary files /dev/null and b/taiga/base/static/img/emojis/feet.png differ diff --git a/taiga/base/static/img/emojis/ferris_wheel.png b/taiga/base/static/img/emojis/ferris_wheel.png new file mode 100644 index 000000000..6bf612d9c Binary files /dev/null and b/taiga/base/static/img/emojis/ferris_wheel.png differ diff --git a/taiga/base/static/img/emojis/file_folder.png b/taiga/base/static/img/emojis/file_folder.png new file mode 100644 index 000000000..2f6549367 Binary files /dev/null and b/taiga/base/static/img/emojis/file_folder.png differ diff --git a/taiga/base/static/img/emojis/finnadie.png b/taiga/base/static/img/emojis/finnadie.png new file mode 100644 index 000000000..8f3f2aa38 Binary files /dev/null and b/taiga/base/static/img/emojis/finnadie.png differ diff --git a/taiga/base/static/img/emojis/fire.png b/taiga/base/static/img/emojis/fire.png new file mode 100644 index 000000000..38292b293 Binary files /dev/null and b/taiga/base/static/img/emojis/fire.png differ diff --git a/taiga/base/static/img/emojis/fire_engine.png b/taiga/base/static/img/emojis/fire_engine.png new file mode 100644 index 000000000..8e879e3e8 Binary files /dev/null and b/taiga/base/static/img/emojis/fire_engine.png differ diff --git a/taiga/base/static/img/emojis/fireworks.png b/taiga/base/static/img/emojis/fireworks.png new file mode 100644 index 000000000..eb53624b8 Binary files /dev/null and b/taiga/base/static/img/emojis/fireworks.png differ diff --git a/taiga/base/static/img/emojis/first_quarter_moon.png b/taiga/base/static/img/emojis/first_quarter_moon.png new file mode 100644 index 000000000..4ccac4067 Binary files /dev/null and b/taiga/base/static/img/emojis/first_quarter_moon.png differ diff --git a/taiga/base/static/img/emojis/first_quarter_moon_with_face.png b/taiga/base/static/img/emojis/first_quarter_moon_with_face.png new file mode 100644 index 000000000..01bf0dc9d Binary files /dev/null and b/taiga/base/static/img/emojis/first_quarter_moon_with_face.png differ diff --git a/taiga/base/static/img/emojis/fish.png b/taiga/base/static/img/emojis/fish.png new file mode 100644 index 000000000..3544ff955 Binary files /dev/null and b/taiga/base/static/img/emojis/fish.png differ diff --git a/taiga/base/static/img/emojis/fish_cake.png b/taiga/base/static/img/emojis/fish_cake.png new file mode 100644 index 000000000..29add6da0 Binary files /dev/null and b/taiga/base/static/img/emojis/fish_cake.png differ diff --git a/taiga/base/static/img/emojis/fishing_pole_and_fish.png b/taiga/base/static/img/emojis/fishing_pole_and_fish.png new file mode 100644 index 000000000..fe1371e91 Binary files /dev/null and b/taiga/base/static/img/emojis/fishing_pole_and_fish.png differ diff --git a/taiga/base/static/img/emojis/fist.png b/taiga/base/static/img/emojis/fist.png new file mode 100644 index 000000000..7d5fe3f56 Binary files /dev/null and b/taiga/base/static/img/emojis/fist.png differ diff --git a/taiga/base/static/img/emojis/five.png b/taiga/base/static/img/emojis/five.png new file mode 100644 index 000000000..3e9c322ed Binary files /dev/null and b/taiga/base/static/img/emojis/five.png differ diff --git a/taiga/base/static/img/emojis/flags.png b/taiga/base/static/img/emojis/flags.png new file mode 100644 index 000000000..0ac33230b Binary files /dev/null and b/taiga/base/static/img/emojis/flags.png differ diff --git a/taiga/base/static/img/emojis/flashlight.png b/taiga/base/static/img/emojis/flashlight.png new file mode 100644 index 000000000..20f3941bd Binary files /dev/null and b/taiga/base/static/img/emojis/flashlight.png differ diff --git a/taiga/base/static/img/emojis/floppy_disk.png b/taiga/base/static/img/emojis/floppy_disk.png new file mode 100644 index 000000000..64362a510 Binary files /dev/null and b/taiga/base/static/img/emojis/floppy_disk.png differ diff --git a/taiga/base/static/img/emojis/flower_playing_cards.png b/taiga/base/static/img/emojis/flower_playing_cards.png new file mode 100644 index 000000000..ef3dfbe03 Binary files /dev/null and b/taiga/base/static/img/emojis/flower_playing_cards.png differ diff --git a/taiga/base/static/img/emojis/flushed.png b/taiga/base/static/img/emojis/flushed.png new file mode 100644 index 000000000..5a8933cb7 Binary files /dev/null and b/taiga/base/static/img/emojis/flushed.png differ diff --git a/taiga/base/static/img/emojis/foggy.png b/taiga/base/static/img/emojis/foggy.png new file mode 100644 index 000000000..d23620c5d Binary files /dev/null and b/taiga/base/static/img/emojis/foggy.png differ diff --git a/taiga/base/static/img/emojis/football.png b/taiga/base/static/img/emojis/football.png new file mode 100644 index 000000000..87e8b0619 Binary files /dev/null and b/taiga/base/static/img/emojis/football.png differ diff --git a/taiga/base/static/img/emojis/fork_and_knife.png b/taiga/base/static/img/emojis/fork_and_knife.png new file mode 100644 index 000000000..5155871eb Binary files /dev/null and b/taiga/base/static/img/emojis/fork_and_knife.png differ diff --git a/taiga/base/static/img/emojis/fountain.png b/taiga/base/static/img/emojis/fountain.png new file mode 100644 index 000000000..f312d939d Binary files /dev/null and b/taiga/base/static/img/emojis/fountain.png differ diff --git a/taiga/base/static/img/emojis/four.png b/taiga/base/static/img/emojis/four.png new file mode 100644 index 000000000..57c65208e Binary files /dev/null and b/taiga/base/static/img/emojis/four.png differ diff --git a/taiga/base/static/img/emojis/four_leaf_clover.png b/taiga/base/static/img/emojis/four_leaf_clover.png new file mode 100644 index 000000000..9f5b641fa Binary files /dev/null and b/taiga/base/static/img/emojis/four_leaf_clover.png differ diff --git a/taiga/base/static/img/emojis/fr.png b/taiga/base/static/img/emojis/fr.png new file mode 100644 index 000000000..f051223dd Binary files /dev/null and b/taiga/base/static/img/emojis/fr.png differ diff --git a/taiga/base/static/img/emojis/free.png b/taiga/base/static/img/emojis/free.png new file mode 100644 index 000000000..1c5d612b7 Binary files /dev/null and b/taiga/base/static/img/emojis/free.png differ diff --git a/taiga/base/static/img/emojis/fried_shrimp.png b/taiga/base/static/img/emojis/fried_shrimp.png new file mode 100644 index 000000000..a65349aca Binary files /dev/null and b/taiga/base/static/img/emojis/fried_shrimp.png differ diff --git a/taiga/base/static/img/emojis/fries.png b/taiga/base/static/img/emojis/fries.png new file mode 100644 index 000000000..b7e535a03 Binary files /dev/null and b/taiga/base/static/img/emojis/fries.png differ diff --git a/taiga/base/static/img/emojis/frog.png b/taiga/base/static/img/emojis/frog.png new file mode 100644 index 000000000..92316aebf Binary files /dev/null and b/taiga/base/static/img/emojis/frog.png differ diff --git a/taiga/base/static/img/emojis/frowning.png b/taiga/base/static/img/emojis/frowning.png new file mode 100644 index 000000000..9f9c188b1 Binary files /dev/null and b/taiga/base/static/img/emojis/frowning.png differ diff --git a/taiga/base/static/img/emojis/fu.png b/taiga/base/static/img/emojis/fu.png new file mode 100644 index 000000000..ad758495f Binary files /dev/null and b/taiga/base/static/img/emojis/fu.png differ diff --git a/taiga/base/static/img/emojis/fuelpump.png b/taiga/base/static/img/emojis/fuelpump.png new file mode 100644 index 000000000..50464a3b8 Binary files /dev/null and b/taiga/base/static/img/emojis/fuelpump.png differ diff --git a/taiga/base/static/img/emojis/full_moon.png b/taiga/base/static/img/emojis/full_moon.png new file mode 100644 index 000000000..858ec71fc Binary files /dev/null and b/taiga/base/static/img/emojis/full_moon.png differ diff --git a/taiga/base/static/img/emojis/full_moon_with_face.png b/taiga/base/static/img/emojis/full_moon_with_face.png new file mode 100644 index 000000000..410a9d457 Binary files /dev/null and b/taiga/base/static/img/emojis/full_moon_with_face.png differ diff --git a/taiga/base/static/img/emojis/game_die.png b/taiga/base/static/img/emojis/game_die.png new file mode 100644 index 000000000..fa8565af0 Binary files /dev/null and b/taiga/base/static/img/emojis/game_die.png differ diff --git a/taiga/base/static/img/emojis/gb.png b/taiga/base/static/img/emojis/gb.png new file mode 100644 index 000000000..025535495 Binary files /dev/null and b/taiga/base/static/img/emojis/gb.png differ diff --git a/taiga/base/static/img/emojis/gem.png b/taiga/base/static/img/emojis/gem.png new file mode 100644 index 000000000..d106d2b4a Binary files /dev/null and b/taiga/base/static/img/emojis/gem.png differ diff --git a/taiga/base/static/img/emojis/gemini.png b/taiga/base/static/img/emojis/gemini.png new file mode 100644 index 000000000..686e046ca Binary files /dev/null and b/taiga/base/static/img/emojis/gemini.png differ diff --git a/taiga/base/static/img/emojis/ghost.png b/taiga/base/static/img/emojis/ghost.png new file mode 100644 index 000000000..fba8b81e5 Binary files /dev/null and b/taiga/base/static/img/emojis/ghost.png differ diff --git a/taiga/base/static/img/emojis/gift.png b/taiga/base/static/img/emojis/gift.png new file mode 100644 index 000000000..2210f531e Binary files /dev/null and b/taiga/base/static/img/emojis/gift.png differ diff --git a/taiga/base/static/img/emojis/gift_heart.png b/taiga/base/static/img/emojis/gift_heart.png new file mode 100644 index 000000000..0805adfeb Binary files /dev/null and b/taiga/base/static/img/emojis/gift_heart.png differ diff --git a/taiga/base/static/img/emojis/girl.png b/taiga/base/static/img/emojis/girl.png new file mode 100644 index 000000000..56ce6e097 Binary files /dev/null and b/taiga/base/static/img/emojis/girl.png differ diff --git a/taiga/base/static/img/emojis/globe_with_meridians.png b/taiga/base/static/img/emojis/globe_with_meridians.png new file mode 100644 index 000000000..6f93dca0f Binary files /dev/null and b/taiga/base/static/img/emojis/globe_with_meridians.png differ diff --git a/taiga/base/static/img/emojis/goat.png b/taiga/base/static/img/emojis/goat.png new file mode 100644 index 000000000..c87eca279 Binary files /dev/null and b/taiga/base/static/img/emojis/goat.png differ diff --git a/taiga/base/static/img/emojis/goberserk.png b/taiga/base/static/img/emojis/goberserk.png new file mode 100644 index 000000000..38e7f7eb0 Binary files /dev/null and b/taiga/base/static/img/emojis/goberserk.png differ diff --git a/taiga/base/static/img/emojis/godmode.png b/taiga/base/static/img/emojis/godmode.png new file mode 100644 index 000000000..55e7f7d4d Binary files /dev/null and b/taiga/base/static/img/emojis/godmode.png differ diff --git a/taiga/base/static/img/emojis/golf.png b/taiga/base/static/img/emojis/golf.png new file mode 100644 index 000000000..64193e429 Binary files /dev/null and b/taiga/base/static/img/emojis/golf.png differ diff --git a/taiga/base/static/img/emojis/grapes.png b/taiga/base/static/img/emojis/grapes.png new file mode 100644 index 000000000..1b0b91025 Binary files /dev/null and b/taiga/base/static/img/emojis/grapes.png differ diff --git a/taiga/base/static/img/emojis/green_apple.png b/taiga/base/static/img/emojis/green_apple.png new file mode 100644 index 000000000..264a125fb Binary files /dev/null and b/taiga/base/static/img/emojis/green_apple.png differ diff --git a/taiga/base/static/img/emojis/green_book.png b/taiga/base/static/img/emojis/green_book.png new file mode 100644 index 000000000..bae6a5521 Binary files /dev/null and b/taiga/base/static/img/emojis/green_book.png differ diff --git a/taiga/base/static/img/emojis/green_heart.png b/taiga/base/static/img/emojis/green_heart.png new file mode 100644 index 000000000..55e25e277 Binary files /dev/null and b/taiga/base/static/img/emojis/green_heart.png differ diff --git a/taiga/base/static/img/emojis/grey_exclamation.png b/taiga/base/static/img/emojis/grey_exclamation.png new file mode 100644 index 000000000..e0d8ff866 Binary files /dev/null and b/taiga/base/static/img/emojis/grey_exclamation.png differ diff --git a/taiga/base/static/img/emojis/grey_question.png b/taiga/base/static/img/emojis/grey_question.png new file mode 100644 index 000000000..3f6acea17 Binary files /dev/null and b/taiga/base/static/img/emojis/grey_question.png differ diff --git a/taiga/base/static/img/emojis/grimacing.png b/taiga/base/static/img/emojis/grimacing.png new file mode 100644 index 000000000..b471487e9 Binary files /dev/null and b/taiga/base/static/img/emojis/grimacing.png differ diff --git a/taiga/base/static/img/emojis/grin.png b/taiga/base/static/img/emojis/grin.png new file mode 100644 index 000000000..c6a5ef3a5 Binary files /dev/null and b/taiga/base/static/img/emojis/grin.png differ diff --git a/taiga/base/static/img/emojis/grinning.png b/taiga/base/static/img/emojis/grinning.png new file mode 100644 index 000000000..94ca708ba Binary files /dev/null and b/taiga/base/static/img/emojis/grinning.png differ diff --git a/taiga/base/static/img/emojis/guardsman.png b/taiga/base/static/img/emojis/guardsman.png new file mode 100644 index 000000000..e4a665975 Binary files /dev/null and b/taiga/base/static/img/emojis/guardsman.png differ diff --git a/taiga/base/static/img/emojis/guitar.png b/taiga/base/static/img/emojis/guitar.png new file mode 100644 index 000000000..9939d795d Binary files /dev/null and b/taiga/base/static/img/emojis/guitar.png differ diff --git a/taiga/base/static/img/emojis/gun.png b/taiga/base/static/img/emojis/gun.png new file mode 100644 index 000000000..5cea9d85d Binary files /dev/null and b/taiga/base/static/img/emojis/gun.png differ diff --git a/taiga/base/static/img/emojis/haircut.png b/taiga/base/static/img/emojis/haircut.png new file mode 100644 index 000000000..68b011f17 Binary files /dev/null and b/taiga/base/static/img/emojis/haircut.png differ diff --git a/taiga/base/static/img/emojis/hamburger.png b/taiga/base/static/img/emojis/hamburger.png new file mode 100644 index 000000000..864d09aa5 Binary files /dev/null and b/taiga/base/static/img/emojis/hamburger.png differ diff --git a/taiga/base/static/img/emojis/hammer.png b/taiga/base/static/img/emojis/hammer.png new file mode 100644 index 000000000..ca527213a Binary files /dev/null and b/taiga/base/static/img/emojis/hammer.png differ diff --git a/taiga/base/static/img/emojis/hamster.png b/taiga/base/static/img/emojis/hamster.png new file mode 100644 index 000000000..3e8d47adc Binary files /dev/null and b/taiga/base/static/img/emojis/hamster.png differ diff --git a/taiga/base/static/img/emojis/hand.png b/taiga/base/static/img/emojis/hand.png new file mode 100644 index 000000000..7537b3750 Binary files /dev/null and b/taiga/base/static/img/emojis/hand.png differ diff --git a/taiga/base/static/img/emojis/handbag.png b/taiga/base/static/img/emojis/handbag.png new file mode 100644 index 000000000..a46d79be2 Binary files /dev/null and b/taiga/base/static/img/emojis/handbag.png differ diff --git a/taiga/base/static/img/emojis/hankey.png b/taiga/base/static/img/emojis/hankey.png new file mode 100644 index 000000000..cbd8d1853 Binary files /dev/null and b/taiga/base/static/img/emojis/hankey.png differ diff --git a/taiga/base/static/img/emojis/hash.png b/taiga/base/static/img/emojis/hash.png new file mode 100644 index 000000000..4082b982b Binary files /dev/null and b/taiga/base/static/img/emojis/hash.png differ diff --git a/taiga/base/static/img/emojis/hatched_chick.png b/taiga/base/static/img/emojis/hatched_chick.png new file mode 100644 index 000000000..c1726be7f Binary files /dev/null and b/taiga/base/static/img/emojis/hatched_chick.png differ diff --git a/taiga/base/static/img/emojis/hatching_chick.png b/taiga/base/static/img/emojis/hatching_chick.png new file mode 100644 index 000000000..7ecebe7a2 Binary files /dev/null and b/taiga/base/static/img/emojis/hatching_chick.png differ diff --git a/taiga/base/static/img/emojis/headphones.png b/taiga/base/static/img/emojis/headphones.png new file mode 100644 index 000000000..b56ebcb30 Binary files /dev/null and b/taiga/base/static/img/emojis/headphones.png differ diff --git a/taiga/base/static/img/emojis/hear_no_evil.png b/taiga/base/static/img/emojis/hear_no_evil.png new file mode 100644 index 000000000..560993ce0 Binary files /dev/null and b/taiga/base/static/img/emojis/hear_no_evil.png differ diff --git a/taiga/base/static/img/emojis/heart.png b/taiga/base/static/img/emojis/heart.png new file mode 100644 index 000000000..43f9b6609 Binary files /dev/null and b/taiga/base/static/img/emojis/heart.png differ diff --git a/taiga/base/static/img/emojis/heart_decoration.png b/taiga/base/static/img/emojis/heart_decoration.png new file mode 100644 index 000000000..2600ef1a2 Binary files /dev/null and b/taiga/base/static/img/emojis/heart_decoration.png differ diff --git a/taiga/base/static/img/emojis/heart_eyes.png b/taiga/base/static/img/emojis/heart_eyes.png new file mode 100644 index 000000000..b2af9da22 Binary files /dev/null and b/taiga/base/static/img/emojis/heart_eyes.png differ diff --git a/taiga/base/static/img/emojis/heart_eyes_cat.png b/taiga/base/static/img/emojis/heart_eyes_cat.png new file mode 100644 index 000000000..26f8bfc66 Binary files /dev/null and b/taiga/base/static/img/emojis/heart_eyes_cat.png differ diff --git a/taiga/base/static/img/emojis/heartbeat.png b/taiga/base/static/img/emojis/heartbeat.png new file mode 100644 index 000000000..608989a5c Binary files /dev/null and b/taiga/base/static/img/emojis/heartbeat.png differ diff --git a/taiga/base/static/img/emojis/heartpulse.png b/taiga/base/static/img/emojis/heartpulse.png new file mode 100644 index 000000000..97dc3ee4e Binary files /dev/null and b/taiga/base/static/img/emojis/heartpulse.png differ diff --git a/taiga/base/static/img/emojis/hearts.png b/taiga/base/static/img/emojis/hearts.png new file mode 100644 index 000000000..fb4e4f7b6 Binary files /dev/null and b/taiga/base/static/img/emojis/hearts.png differ diff --git a/taiga/base/static/img/emojis/heavy_check_mark.png b/taiga/base/static/img/emojis/heavy_check_mark.png new file mode 100644 index 000000000..59c25db1b Binary files /dev/null and b/taiga/base/static/img/emojis/heavy_check_mark.png differ diff --git a/taiga/base/static/img/emojis/heavy_division_sign.png b/taiga/base/static/img/emojis/heavy_division_sign.png new file mode 100644 index 000000000..27ee96492 Binary files /dev/null and b/taiga/base/static/img/emojis/heavy_division_sign.png differ diff --git a/taiga/base/static/img/emojis/heavy_dollar_sign.png b/taiga/base/static/img/emojis/heavy_dollar_sign.png new file mode 100644 index 000000000..065f96a83 Binary files /dev/null and b/taiga/base/static/img/emojis/heavy_dollar_sign.png differ diff --git a/taiga/base/static/img/emojis/heavy_exclamation_mark.png b/taiga/base/static/img/emojis/heavy_exclamation_mark.png new file mode 100644 index 000000000..a92d1ed08 Binary files /dev/null and b/taiga/base/static/img/emojis/heavy_exclamation_mark.png differ diff --git a/taiga/base/static/img/emojis/heavy_minus_sign.png b/taiga/base/static/img/emojis/heavy_minus_sign.png new file mode 100644 index 000000000..032d0e501 Binary files /dev/null and b/taiga/base/static/img/emojis/heavy_minus_sign.png differ diff --git a/taiga/base/static/img/emojis/heavy_multiplication_x.png b/taiga/base/static/img/emojis/heavy_multiplication_x.png new file mode 100644 index 000000000..4cf30a905 Binary files /dev/null and b/taiga/base/static/img/emojis/heavy_multiplication_x.png differ diff --git a/taiga/base/static/img/emojis/heavy_plus_sign.png b/taiga/base/static/img/emojis/heavy_plus_sign.png new file mode 100644 index 000000000..0a0f23316 Binary files /dev/null and b/taiga/base/static/img/emojis/heavy_plus_sign.png differ diff --git a/taiga/base/static/img/emojis/helicopter.png b/taiga/base/static/img/emojis/helicopter.png new file mode 100644 index 000000000..d40615d0b Binary files /dev/null and b/taiga/base/static/img/emojis/helicopter.png differ diff --git a/taiga/base/static/img/emojis/herb.png b/taiga/base/static/img/emojis/herb.png new file mode 100644 index 000000000..cd151993f Binary files /dev/null and b/taiga/base/static/img/emojis/herb.png differ diff --git a/taiga/base/static/img/emojis/hibiscus.png b/taiga/base/static/img/emojis/hibiscus.png new file mode 100644 index 000000000..6d291b756 Binary files /dev/null and b/taiga/base/static/img/emojis/hibiscus.png differ diff --git a/taiga/base/static/img/emojis/high_brightness.png b/taiga/base/static/img/emojis/high_brightness.png new file mode 100644 index 000000000..931856b7a Binary files /dev/null and b/taiga/base/static/img/emojis/high_brightness.png differ diff --git a/taiga/base/static/img/emojis/high_heel.png b/taiga/base/static/img/emojis/high_heel.png new file mode 100644 index 000000000..d065a29ed Binary files /dev/null and b/taiga/base/static/img/emojis/high_heel.png differ diff --git a/taiga/base/static/img/emojis/hocho.png b/taiga/base/static/img/emojis/hocho.png new file mode 100644 index 000000000..5b3cf0f5f Binary files /dev/null and b/taiga/base/static/img/emojis/hocho.png differ diff --git a/taiga/base/static/img/emojis/honey_pot.png b/taiga/base/static/img/emojis/honey_pot.png new file mode 100644 index 000000000..6a30762f6 Binary files /dev/null and b/taiga/base/static/img/emojis/honey_pot.png differ diff --git a/taiga/base/static/img/emojis/honeybee.png b/taiga/base/static/img/emojis/honeybee.png new file mode 100644 index 000000000..1c5fc5bf0 Binary files /dev/null and b/taiga/base/static/img/emojis/honeybee.png differ diff --git a/taiga/base/static/img/emojis/horse.png b/taiga/base/static/img/emojis/horse.png new file mode 100644 index 000000000..e0c1f2e81 Binary files /dev/null and b/taiga/base/static/img/emojis/horse.png differ diff --git a/taiga/base/static/img/emojis/horse_racing.png b/taiga/base/static/img/emojis/horse_racing.png new file mode 100644 index 000000000..f9d19d2ba Binary files /dev/null and b/taiga/base/static/img/emojis/horse_racing.png differ diff --git a/taiga/base/static/img/emojis/hospital.png b/taiga/base/static/img/emojis/hospital.png new file mode 100644 index 000000000..3d5f71ed3 Binary files /dev/null and b/taiga/base/static/img/emojis/hospital.png differ diff --git a/taiga/base/static/img/emojis/hotel.png b/taiga/base/static/img/emojis/hotel.png new file mode 100644 index 000000000..ee06286c8 Binary files /dev/null and b/taiga/base/static/img/emojis/hotel.png differ diff --git a/taiga/base/static/img/emojis/hotsprings.png b/taiga/base/static/img/emojis/hotsprings.png new file mode 100644 index 000000000..c3313269c Binary files /dev/null and b/taiga/base/static/img/emojis/hotsprings.png differ diff --git a/taiga/base/static/img/emojis/hourglass.png b/taiga/base/static/img/emojis/hourglass.png new file mode 100644 index 000000000..e30b6b619 Binary files /dev/null and b/taiga/base/static/img/emojis/hourglass.png differ diff --git a/taiga/base/static/img/emojis/hourglass_flowing_sand.png b/taiga/base/static/img/emojis/hourglass_flowing_sand.png new file mode 100644 index 000000000..9415f644f Binary files /dev/null and b/taiga/base/static/img/emojis/hourglass_flowing_sand.png differ diff --git a/taiga/base/static/img/emojis/house.png b/taiga/base/static/img/emojis/house.png new file mode 100644 index 000000000..372dc5762 Binary files /dev/null and b/taiga/base/static/img/emojis/house.png differ diff --git a/taiga/base/static/img/emojis/house_with_garden.png b/taiga/base/static/img/emojis/house_with_garden.png new file mode 100644 index 000000000..51114ea63 Binary files /dev/null and b/taiga/base/static/img/emojis/house_with_garden.png differ diff --git a/taiga/base/static/img/emojis/hurtrealbad.png b/taiga/base/static/img/emojis/hurtrealbad.png new file mode 100644 index 000000000..79c67f508 Binary files /dev/null and b/taiga/base/static/img/emojis/hurtrealbad.png differ diff --git a/taiga/base/static/img/emojis/hushed.png b/taiga/base/static/img/emojis/hushed.png new file mode 100644 index 000000000..75007b6f6 Binary files /dev/null and b/taiga/base/static/img/emojis/hushed.png differ diff --git a/taiga/base/static/img/emojis/ice_cream.png b/taiga/base/static/img/emojis/ice_cream.png new file mode 100644 index 000000000..8911e16c0 Binary files /dev/null and b/taiga/base/static/img/emojis/ice_cream.png differ diff --git a/taiga/base/static/img/emojis/icecream.png b/taiga/base/static/img/emojis/icecream.png new file mode 100644 index 000000000..33fde29cc Binary files /dev/null and b/taiga/base/static/img/emojis/icecream.png differ diff --git a/taiga/base/static/img/emojis/id.png b/taiga/base/static/img/emojis/id.png new file mode 100644 index 000000000..565735dba Binary files /dev/null and b/taiga/base/static/img/emojis/id.png differ diff --git a/taiga/base/static/img/emojis/ideograph_advantage.png b/taiga/base/static/img/emojis/ideograph_advantage.png new file mode 100644 index 000000000..76a52f56d Binary files /dev/null and b/taiga/base/static/img/emojis/ideograph_advantage.png differ diff --git a/taiga/base/static/img/emojis/imp.png b/taiga/base/static/img/emojis/imp.png new file mode 100644 index 000000000..dc8ca586a Binary files /dev/null and b/taiga/base/static/img/emojis/imp.png differ diff --git a/taiga/base/static/img/emojis/inbox_tray.png b/taiga/base/static/img/emojis/inbox_tray.png new file mode 100644 index 000000000..46a67e44c Binary files /dev/null and b/taiga/base/static/img/emojis/inbox_tray.png differ diff --git a/taiga/base/static/img/emojis/incoming_envelope.png b/taiga/base/static/img/emojis/incoming_envelope.png new file mode 100644 index 000000000..c43c1e1b5 Binary files /dev/null and b/taiga/base/static/img/emojis/incoming_envelope.png differ diff --git a/taiga/base/static/img/emojis/information_desk_person.png b/taiga/base/static/img/emojis/information_desk_person.png new file mode 100644 index 000000000..ad75a6c75 Binary files /dev/null and b/taiga/base/static/img/emojis/information_desk_person.png differ diff --git a/taiga/base/static/img/emojis/information_source.png b/taiga/base/static/img/emojis/information_source.png new file mode 100644 index 000000000..bc601c251 Binary files /dev/null and b/taiga/base/static/img/emojis/information_source.png differ diff --git a/taiga/base/static/img/emojis/innocent.png b/taiga/base/static/img/emojis/innocent.png new file mode 100644 index 000000000..1da31839e Binary files /dev/null and b/taiga/base/static/img/emojis/innocent.png differ diff --git a/taiga/base/static/img/emojis/interrobang.png b/taiga/base/static/img/emojis/interrobang.png new file mode 100644 index 000000000..4159e389d Binary files /dev/null and b/taiga/base/static/img/emojis/interrobang.png differ diff --git a/taiga/base/static/img/emojis/iphone.png b/taiga/base/static/img/emojis/iphone.png new file mode 100644 index 000000000..1a1955635 Binary files /dev/null and b/taiga/base/static/img/emojis/iphone.png differ diff --git a/taiga/base/static/img/emojis/it.png b/taiga/base/static/img/emojis/it.png new file mode 100644 index 000000000..93479805e Binary files /dev/null and b/taiga/base/static/img/emojis/it.png differ diff --git a/taiga/base/static/img/emojis/izakaya_lantern.png b/taiga/base/static/img/emojis/izakaya_lantern.png new file mode 100644 index 000000000..daeece4b4 Binary files /dev/null and b/taiga/base/static/img/emojis/izakaya_lantern.png differ diff --git a/taiga/base/static/img/emojis/jack_o_lantern.png b/taiga/base/static/img/emojis/jack_o_lantern.png new file mode 100644 index 000000000..a565b482e Binary files /dev/null and b/taiga/base/static/img/emojis/jack_o_lantern.png differ diff --git a/taiga/base/static/img/emojis/japan.png b/taiga/base/static/img/emojis/japan.png new file mode 100644 index 000000000..4ff907919 Binary files /dev/null and b/taiga/base/static/img/emojis/japan.png differ diff --git a/taiga/base/static/img/emojis/japanese_castle.png b/taiga/base/static/img/emojis/japanese_castle.png new file mode 100644 index 000000000..4fe4baaa9 Binary files /dev/null and b/taiga/base/static/img/emojis/japanese_castle.png differ diff --git a/taiga/base/static/img/emojis/japanese_goblin.png b/taiga/base/static/img/emojis/japanese_goblin.png new file mode 100644 index 000000000..5407007cf Binary files /dev/null and b/taiga/base/static/img/emojis/japanese_goblin.png differ diff --git a/taiga/base/static/img/emojis/japanese_ogre.png b/taiga/base/static/img/emojis/japanese_ogre.png new file mode 100644 index 000000000..15d319f81 Binary files /dev/null and b/taiga/base/static/img/emojis/japanese_ogre.png differ diff --git a/taiga/base/static/img/emojis/jeans.png b/taiga/base/static/img/emojis/jeans.png new file mode 100644 index 000000000..449163284 Binary files /dev/null and b/taiga/base/static/img/emojis/jeans.png differ diff --git a/taiga/base/static/img/emojis/joy.png b/taiga/base/static/img/emojis/joy.png new file mode 100644 index 000000000..4e7742a0a Binary files /dev/null and b/taiga/base/static/img/emojis/joy.png differ diff --git a/taiga/base/static/img/emojis/joy_cat.png b/taiga/base/static/img/emojis/joy_cat.png new file mode 100644 index 000000000..3b281f69a Binary files /dev/null and b/taiga/base/static/img/emojis/joy_cat.png differ diff --git a/taiga/base/static/img/emojis/jp.png b/taiga/base/static/img/emojis/jp.png new file mode 100644 index 000000000..954b20f2d Binary files /dev/null and b/taiga/base/static/img/emojis/jp.png differ diff --git a/taiga/base/static/img/emojis/key.png b/taiga/base/static/img/emojis/key.png new file mode 100644 index 000000000..c85ceae30 Binary files /dev/null and b/taiga/base/static/img/emojis/key.png differ diff --git a/taiga/base/static/img/emojis/keycap_ten.png b/taiga/base/static/img/emojis/keycap_ten.png new file mode 100644 index 000000000..5bb744a4f Binary files /dev/null and b/taiga/base/static/img/emojis/keycap_ten.png differ diff --git a/taiga/base/static/img/emojis/kimono.png b/taiga/base/static/img/emojis/kimono.png new file mode 100644 index 000000000..d44d66ae1 Binary files /dev/null and b/taiga/base/static/img/emojis/kimono.png differ diff --git a/taiga/base/static/img/emojis/kiss.png b/taiga/base/static/img/emojis/kiss.png new file mode 100644 index 000000000..b12afaaed Binary files /dev/null and b/taiga/base/static/img/emojis/kiss.png differ diff --git a/taiga/base/static/img/emojis/kissing.png b/taiga/base/static/img/emojis/kissing.png new file mode 100644 index 000000000..9b4c4c00a Binary files /dev/null and b/taiga/base/static/img/emojis/kissing.png differ diff --git a/taiga/base/static/img/emojis/kissing_cat.png b/taiga/base/static/img/emojis/kissing_cat.png new file mode 100644 index 000000000..69e24f9ff Binary files /dev/null and b/taiga/base/static/img/emojis/kissing_cat.png differ diff --git a/taiga/base/static/img/emojis/kissing_closed_eyes.png b/taiga/base/static/img/emojis/kissing_closed_eyes.png new file mode 100644 index 000000000..d1aaf59be Binary files /dev/null and b/taiga/base/static/img/emojis/kissing_closed_eyes.png differ diff --git a/taiga/base/static/img/emojis/kissing_face.png b/taiga/base/static/img/emojis/kissing_face.png new file mode 100644 index 000000000..d1aaf59be Binary files /dev/null and b/taiga/base/static/img/emojis/kissing_face.png differ diff --git a/taiga/base/static/img/emojis/kissing_heart.png b/taiga/base/static/img/emojis/kissing_heart.png new file mode 100644 index 000000000..9a96154c7 Binary files /dev/null and b/taiga/base/static/img/emojis/kissing_heart.png differ diff --git a/taiga/base/static/img/emojis/kissing_smiling_eyes.png b/taiga/base/static/img/emojis/kissing_smiling_eyes.png new file mode 100644 index 000000000..ffb5d8342 Binary files /dev/null and b/taiga/base/static/img/emojis/kissing_smiling_eyes.png differ diff --git a/taiga/base/static/img/emojis/koala.png b/taiga/base/static/img/emojis/koala.png new file mode 100644 index 000000000..c25002346 Binary files /dev/null and b/taiga/base/static/img/emojis/koala.png differ diff --git a/taiga/base/static/img/emojis/koko.png b/taiga/base/static/img/emojis/koko.png new file mode 100644 index 000000000..65937c9f4 Binary files /dev/null and b/taiga/base/static/img/emojis/koko.png differ diff --git a/taiga/base/static/img/emojis/kr.png b/taiga/base/static/img/emojis/kr.png new file mode 100644 index 000000000..4dad585ef Binary files /dev/null and b/taiga/base/static/img/emojis/kr.png differ diff --git a/taiga/base/static/img/emojis/large_blue_circle.png b/taiga/base/static/img/emojis/large_blue_circle.png new file mode 100644 index 000000000..285185541 Binary files /dev/null and b/taiga/base/static/img/emojis/large_blue_circle.png differ diff --git a/taiga/base/static/img/emojis/large_blue_diamond.png b/taiga/base/static/img/emojis/large_blue_diamond.png new file mode 100644 index 000000000..86ced7aed Binary files /dev/null and b/taiga/base/static/img/emojis/large_blue_diamond.png differ diff --git a/taiga/base/static/img/emojis/large_orange_diamond.png b/taiga/base/static/img/emojis/large_orange_diamond.png new file mode 100644 index 000000000..9c509f8a5 Binary files /dev/null and b/taiga/base/static/img/emojis/large_orange_diamond.png differ diff --git a/taiga/base/static/img/emojis/last_quarter_moon.png b/taiga/base/static/img/emojis/last_quarter_moon.png new file mode 100644 index 000000000..368be3f0c Binary files /dev/null and b/taiga/base/static/img/emojis/last_quarter_moon.png differ diff --git a/taiga/base/static/img/emojis/last_quarter_moon_with_face.png b/taiga/base/static/img/emojis/last_quarter_moon_with_face.png new file mode 100644 index 000000000..5a890d729 Binary files /dev/null and b/taiga/base/static/img/emojis/last_quarter_moon_with_face.png differ diff --git a/taiga/base/static/img/emojis/laughing.png b/taiga/base/static/img/emojis/laughing.png new file mode 100644 index 000000000..5f0703b33 Binary files /dev/null and b/taiga/base/static/img/emojis/laughing.png differ diff --git a/taiga/base/static/img/emojis/leaves.png b/taiga/base/static/img/emojis/leaves.png new file mode 100644 index 000000000..6b94eee73 Binary files /dev/null and b/taiga/base/static/img/emojis/leaves.png differ diff --git a/taiga/base/static/img/emojis/ledger.png b/taiga/base/static/img/emojis/ledger.png new file mode 100644 index 000000000..006521dea Binary files /dev/null and b/taiga/base/static/img/emojis/ledger.png differ diff --git a/taiga/base/static/img/emojis/left_luggage.png b/taiga/base/static/img/emojis/left_luggage.png new file mode 100644 index 000000000..f88e13a44 Binary files /dev/null and b/taiga/base/static/img/emojis/left_luggage.png differ diff --git a/taiga/base/static/img/emojis/left_right_arrow.png b/taiga/base/static/img/emojis/left_right_arrow.png new file mode 100644 index 000000000..e69095a37 Binary files /dev/null and b/taiga/base/static/img/emojis/left_right_arrow.png differ diff --git a/taiga/base/static/img/emojis/leftwards_arrow_with_hook.png b/taiga/base/static/img/emojis/leftwards_arrow_with_hook.png new file mode 100644 index 000000000..9df9981f0 Binary files /dev/null and b/taiga/base/static/img/emojis/leftwards_arrow_with_hook.png differ diff --git a/taiga/base/static/img/emojis/lemon.png b/taiga/base/static/img/emojis/lemon.png new file mode 100644 index 000000000..8b32e5fce Binary files /dev/null and b/taiga/base/static/img/emojis/lemon.png differ diff --git a/taiga/base/static/img/emojis/leo.png b/taiga/base/static/img/emojis/leo.png new file mode 100644 index 000000000..df943ee4a Binary files /dev/null and b/taiga/base/static/img/emojis/leo.png differ diff --git a/taiga/base/static/img/emojis/leopard.png b/taiga/base/static/img/emojis/leopard.png new file mode 100644 index 000000000..e5fd37b1d Binary files /dev/null and b/taiga/base/static/img/emojis/leopard.png differ diff --git a/taiga/base/static/img/emojis/libra.png b/taiga/base/static/img/emojis/libra.png new file mode 100644 index 000000000..1e31fcbbc Binary files /dev/null and b/taiga/base/static/img/emojis/libra.png differ diff --git a/taiga/base/static/img/emojis/light_rail.png b/taiga/base/static/img/emojis/light_rail.png new file mode 100644 index 000000000..67bb57983 Binary files /dev/null and b/taiga/base/static/img/emojis/light_rail.png differ diff --git a/taiga/base/static/img/emojis/link.png b/taiga/base/static/img/emojis/link.png new file mode 100644 index 000000000..4ebf1ca58 Binary files /dev/null and b/taiga/base/static/img/emojis/link.png differ diff --git a/taiga/base/static/img/emojis/lips.png b/taiga/base/static/img/emojis/lips.png new file mode 100644 index 000000000..bbbdec696 Binary files /dev/null and b/taiga/base/static/img/emojis/lips.png differ diff --git a/taiga/base/static/img/emojis/lipstick.png b/taiga/base/static/img/emojis/lipstick.png new file mode 100644 index 000000000..6cd519377 Binary files /dev/null and b/taiga/base/static/img/emojis/lipstick.png differ diff --git a/taiga/base/static/img/emojis/lock.png b/taiga/base/static/img/emojis/lock.png new file mode 100644 index 000000000..1ab342740 Binary files /dev/null and b/taiga/base/static/img/emojis/lock.png differ diff --git a/taiga/base/static/img/emojis/lock_with_ink_pen.png b/taiga/base/static/img/emojis/lock_with_ink_pen.png new file mode 100644 index 000000000..b559b6dd3 Binary files /dev/null and b/taiga/base/static/img/emojis/lock_with_ink_pen.png differ diff --git a/taiga/base/static/img/emojis/lollipop.png b/taiga/base/static/img/emojis/lollipop.png new file mode 100644 index 000000000..c6b1f3b17 Binary files /dev/null and b/taiga/base/static/img/emojis/lollipop.png differ diff --git a/taiga/base/static/img/emojis/loop.png b/taiga/base/static/img/emojis/loop.png new file mode 100644 index 000000000..9c7034230 Binary files /dev/null and b/taiga/base/static/img/emojis/loop.png differ diff --git a/taiga/base/static/img/emojis/loudspeaker.png b/taiga/base/static/img/emojis/loudspeaker.png new file mode 100644 index 000000000..54aa6b2b5 Binary files /dev/null and b/taiga/base/static/img/emojis/loudspeaker.png differ diff --git a/taiga/base/static/img/emojis/love_hotel.png b/taiga/base/static/img/emojis/love_hotel.png new file mode 100644 index 000000000..4bec1023b Binary files /dev/null and b/taiga/base/static/img/emojis/love_hotel.png differ diff --git a/taiga/base/static/img/emojis/love_letter.png b/taiga/base/static/img/emojis/love_letter.png new file mode 100644 index 000000000..b599a6061 Binary files /dev/null and b/taiga/base/static/img/emojis/love_letter.png differ diff --git a/taiga/base/static/img/emojis/low_brightness.png b/taiga/base/static/img/emojis/low_brightness.png new file mode 100644 index 000000000..91dcc0db8 Binary files /dev/null and b/taiga/base/static/img/emojis/low_brightness.png differ diff --git a/taiga/base/static/img/emojis/m.png b/taiga/base/static/img/emojis/m.png new file mode 100644 index 000000000..2002b7740 Binary files /dev/null and b/taiga/base/static/img/emojis/m.png differ diff --git a/taiga/base/static/img/emojis/mag.png b/taiga/base/static/img/emojis/mag.png new file mode 100644 index 000000000..ae981d662 Binary files /dev/null and b/taiga/base/static/img/emojis/mag.png differ diff --git a/taiga/base/static/img/emojis/mag_right.png b/taiga/base/static/img/emojis/mag_right.png new file mode 100644 index 000000000..bf8cf4938 Binary files /dev/null and b/taiga/base/static/img/emojis/mag_right.png differ diff --git a/taiga/base/static/img/emojis/mahjong.png b/taiga/base/static/img/emojis/mahjong.png new file mode 100644 index 000000000..0fd6e6d66 Binary files /dev/null and b/taiga/base/static/img/emojis/mahjong.png differ diff --git a/taiga/base/static/img/emojis/mailbox.png b/taiga/base/static/img/emojis/mailbox.png new file mode 100644 index 000000000..5291564e3 Binary files /dev/null and b/taiga/base/static/img/emojis/mailbox.png differ diff --git a/taiga/base/static/img/emojis/mailbox_closed.png b/taiga/base/static/img/emojis/mailbox_closed.png new file mode 100644 index 000000000..d1b07255e Binary files /dev/null and b/taiga/base/static/img/emojis/mailbox_closed.png differ diff --git a/taiga/base/static/img/emojis/mailbox_with_mail.png b/taiga/base/static/img/emojis/mailbox_with_mail.png new file mode 100644 index 000000000..7cf017110 Binary files /dev/null and b/taiga/base/static/img/emojis/mailbox_with_mail.png differ diff --git a/taiga/base/static/img/emojis/mailbox_with_no_mail.png b/taiga/base/static/img/emojis/mailbox_with_no_mail.png new file mode 100644 index 000000000..b3d41b3cf Binary files /dev/null and b/taiga/base/static/img/emojis/mailbox_with_no_mail.png differ diff --git a/taiga/base/static/img/emojis/man.png b/taiga/base/static/img/emojis/man.png new file mode 100644 index 000000000..3829211db Binary files /dev/null and b/taiga/base/static/img/emojis/man.png differ diff --git a/taiga/base/static/img/emojis/man_with_gua_pi_mao.png b/taiga/base/static/img/emojis/man_with_gua_pi_mao.png new file mode 100644 index 000000000..d59ca4dcb Binary files /dev/null and b/taiga/base/static/img/emojis/man_with_gua_pi_mao.png differ diff --git a/taiga/base/static/img/emojis/man_with_turban.png b/taiga/base/static/img/emojis/man_with_turban.png new file mode 100644 index 000000000..96d01c635 Binary files /dev/null and b/taiga/base/static/img/emojis/man_with_turban.png differ diff --git a/taiga/base/static/img/emojis/mans_shoe.png b/taiga/base/static/img/emojis/mans_shoe.png new file mode 100644 index 000000000..31459aa95 Binary files /dev/null and b/taiga/base/static/img/emojis/mans_shoe.png differ diff --git a/taiga/base/static/img/emojis/maple_leaf.png b/taiga/base/static/img/emojis/maple_leaf.png new file mode 100644 index 000000000..48cc9b22f Binary files /dev/null and b/taiga/base/static/img/emojis/maple_leaf.png differ diff --git a/taiga/base/static/img/emojis/mask.png b/taiga/base/static/img/emojis/mask.png new file mode 100644 index 000000000..acc4c47ce Binary files /dev/null and b/taiga/base/static/img/emojis/mask.png differ diff --git a/taiga/base/static/img/emojis/massage.png b/taiga/base/static/img/emojis/massage.png new file mode 100644 index 000000000..8445b7263 Binary files /dev/null and b/taiga/base/static/img/emojis/massage.png differ diff --git a/taiga/base/static/img/emojis/meat_on_bone.png b/taiga/base/static/img/emojis/meat_on_bone.png new file mode 100644 index 000000000..689697cb4 Binary files /dev/null and b/taiga/base/static/img/emojis/meat_on_bone.png differ diff --git a/taiga/base/static/img/emojis/mega.png b/taiga/base/static/img/emojis/mega.png new file mode 100644 index 000000000..c41ef7177 Binary files /dev/null and b/taiga/base/static/img/emojis/mega.png differ diff --git a/taiga/base/static/img/emojis/melon.png b/taiga/base/static/img/emojis/melon.png new file mode 100644 index 000000000..3dcce4edd Binary files /dev/null and b/taiga/base/static/img/emojis/melon.png differ diff --git a/taiga/base/static/img/emojis/memo.png b/taiga/base/static/img/emojis/memo.png new file mode 100644 index 000000000..e0e7b956d Binary files /dev/null and b/taiga/base/static/img/emojis/memo.png differ diff --git a/taiga/base/static/img/emojis/mens.png b/taiga/base/static/img/emojis/mens.png new file mode 100644 index 000000000..1ece8be8f Binary files /dev/null and b/taiga/base/static/img/emojis/mens.png differ diff --git a/taiga/base/static/img/emojis/metal.png b/taiga/base/static/img/emojis/metal.png new file mode 100644 index 000000000..bc27d0a06 Binary files /dev/null and b/taiga/base/static/img/emojis/metal.png differ diff --git a/taiga/base/static/img/emojis/metro.png b/taiga/base/static/img/emojis/metro.png new file mode 100644 index 000000000..095268c8f Binary files /dev/null and b/taiga/base/static/img/emojis/metro.png differ diff --git a/taiga/base/static/img/emojis/microphone.png b/taiga/base/static/img/emojis/microphone.png new file mode 100644 index 000000000..95518a17b Binary files /dev/null and b/taiga/base/static/img/emojis/microphone.png differ diff --git a/taiga/base/static/img/emojis/microscope.png b/taiga/base/static/img/emojis/microscope.png new file mode 100644 index 000000000..e2514a187 Binary files /dev/null and b/taiga/base/static/img/emojis/microscope.png differ diff --git a/taiga/base/static/img/emojis/milky_way.png b/taiga/base/static/img/emojis/milky_way.png new file mode 100644 index 000000000..7597c5f16 Binary files /dev/null and b/taiga/base/static/img/emojis/milky_way.png differ diff --git a/taiga/base/static/img/emojis/minibus.png b/taiga/base/static/img/emojis/minibus.png new file mode 100644 index 000000000..8c3b91c1e Binary files /dev/null and b/taiga/base/static/img/emojis/minibus.png differ diff --git a/taiga/base/static/img/emojis/minidisc.png b/taiga/base/static/img/emojis/minidisc.png new file mode 100644 index 000000000..fc885dbe5 Binary files /dev/null and b/taiga/base/static/img/emojis/minidisc.png differ diff --git a/taiga/base/static/img/emojis/mobile_phone_off.png b/taiga/base/static/img/emojis/mobile_phone_off.png new file mode 100644 index 000000000..d103b4a97 Binary files /dev/null and b/taiga/base/static/img/emojis/mobile_phone_off.png differ diff --git a/taiga/base/static/img/emojis/money_with_wings.png b/taiga/base/static/img/emojis/money_with_wings.png new file mode 100644 index 000000000..f3c286f2d Binary files /dev/null and b/taiga/base/static/img/emojis/money_with_wings.png differ diff --git a/taiga/base/static/img/emojis/moneybag.png b/taiga/base/static/img/emojis/moneybag.png new file mode 100644 index 000000000..8d6ceb88c Binary files /dev/null and b/taiga/base/static/img/emojis/moneybag.png differ diff --git a/taiga/base/static/img/emojis/monkey.png b/taiga/base/static/img/emojis/monkey.png new file mode 100644 index 000000000..6778857ea Binary files /dev/null and b/taiga/base/static/img/emojis/monkey.png differ diff --git a/taiga/base/static/img/emojis/monkey_face.png b/taiga/base/static/img/emojis/monkey_face.png new file mode 100644 index 000000000..c6762613a Binary files /dev/null and b/taiga/base/static/img/emojis/monkey_face.png differ diff --git a/taiga/base/static/img/emojis/monorail.png b/taiga/base/static/img/emojis/monorail.png new file mode 100644 index 000000000..6fb2b483d Binary files /dev/null and b/taiga/base/static/img/emojis/monorail.png differ diff --git a/taiga/base/static/img/emojis/moon.png b/taiga/base/static/img/emojis/moon.png new file mode 100644 index 000000000..53abe6a4b Binary files /dev/null and b/taiga/base/static/img/emojis/moon.png differ diff --git a/taiga/base/static/img/emojis/mortar_board.png b/taiga/base/static/img/emojis/mortar_board.png new file mode 100644 index 000000000..4173c9ae1 Binary files /dev/null and b/taiga/base/static/img/emojis/mortar_board.png differ diff --git a/taiga/base/static/img/emojis/mount_fuji.png b/taiga/base/static/img/emojis/mount_fuji.png new file mode 100644 index 000000000..6b4004c5a Binary files /dev/null and b/taiga/base/static/img/emojis/mount_fuji.png differ diff --git a/taiga/base/static/img/emojis/mountain_bicyclist.png b/taiga/base/static/img/emojis/mountain_bicyclist.png new file mode 100644 index 000000000..9a212679b Binary files /dev/null and b/taiga/base/static/img/emojis/mountain_bicyclist.png differ diff --git a/taiga/base/static/img/emojis/mountain_cableway.png b/taiga/base/static/img/emojis/mountain_cableway.png new file mode 100644 index 000000000..947fcd85a Binary files /dev/null and b/taiga/base/static/img/emojis/mountain_cableway.png differ diff --git a/taiga/base/static/img/emojis/mountain_railway.png b/taiga/base/static/img/emojis/mountain_railway.png new file mode 100644 index 000000000..b252c0ccc Binary files /dev/null and b/taiga/base/static/img/emojis/mountain_railway.png differ diff --git a/taiga/base/static/img/emojis/mouse.png b/taiga/base/static/img/emojis/mouse.png new file mode 100644 index 000000000..38cbc0795 Binary files /dev/null and b/taiga/base/static/img/emojis/mouse.png differ diff --git a/taiga/base/static/img/emojis/mouse2.png b/taiga/base/static/img/emojis/mouse2.png new file mode 100644 index 000000000..45122ed2f Binary files /dev/null and b/taiga/base/static/img/emojis/mouse2.png differ diff --git a/taiga/base/static/img/emojis/movie_camera.png b/taiga/base/static/img/emojis/movie_camera.png new file mode 100644 index 000000000..bffa60d9f Binary files /dev/null and b/taiga/base/static/img/emojis/movie_camera.png differ diff --git a/taiga/base/static/img/emojis/moyai.png b/taiga/base/static/img/emojis/moyai.png new file mode 100644 index 000000000..e788b256c Binary files /dev/null and b/taiga/base/static/img/emojis/moyai.png differ diff --git a/taiga/base/static/img/emojis/muscle.png b/taiga/base/static/img/emojis/muscle.png new file mode 100644 index 000000000..884d289da Binary files /dev/null and b/taiga/base/static/img/emojis/muscle.png differ diff --git a/taiga/base/static/img/emojis/mushroom.png b/taiga/base/static/img/emojis/mushroom.png new file mode 100644 index 000000000..0bed51b28 Binary files /dev/null and b/taiga/base/static/img/emojis/mushroom.png differ diff --git a/taiga/base/static/img/emojis/musical_keyboard.png b/taiga/base/static/img/emojis/musical_keyboard.png new file mode 100644 index 000000000..aad745ad0 Binary files /dev/null and b/taiga/base/static/img/emojis/musical_keyboard.png differ diff --git a/taiga/base/static/img/emojis/musical_note.png b/taiga/base/static/img/emojis/musical_note.png new file mode 100644 index 000000000..a78737dee Binary files /dev/null and b/taiga/base/static/img/emojis/musical_note.png differ diff --git a/taiga/base/static/img/emojis/musical_score.png b/taiga/base/static/img/emojis/musical_score.png new file mode 100644 index 000000000..082c2b2ad Binary files /dev/null and b/taiga/base/static/img/emojis/musical_score.png differ diff --git a/taiga/base/static/img/emojis/mute.png b/taiga/base/static/img/emojis/mute.png new file mode 100644 index 000000000..5638596f2 Binary files /dev/null and b/taiga/base/static/img/emojis/mute.png differ diff --git a/taiga/base/static/img/emojis/nail_care.png b/taiga/base/static/img/emojis/nail_care.png new file mode 100644 index 000000000..2734aee75 Binary files /dev/null and b/taiga/base/static/img/emojis/nail_care.png differ diff --git a/taiga/base/static/img/emojis/name_badge.png b/taiga/base/static/img/emojis/name_badge.png new file mode 100644 index 000000000..ffa604596 Binary files /dev/null and b/taiga/base/static/img/emojis/name_badge.png differ diff --git a/taiga/base/static/img/emojis/neckbeard.png b/taiga/base/static/img/emojis/neckbeard.png new file mode 100644 index 000000000..b52ce29f9 Binary files /dev/null and b/taiga/base/static/img/emojis/neckbeard.png differ diff --git a/taiga/base/static/img/emojis/necktie.png b/taiga/base/static/img/emojis/necktie.png new file mode 100644 index 000000000..52cd59974 Binary files /dev/null and b/taiga/base/static/img/emojis/necktie.png differ diff --git a/taiga/base/static/img/emojis/negative_squared_cross_mark.png b/taiga/base/static/img/emojis/negative_squared_cross_mark.png new file mode 100644 index 000000000..3c2f7e46f Binary files /dev/null and b/taiga/base/static/img/emojis/negative_squared_cross_mark.png differ diff --git a/taiga/base/static/img/emojis/neutral_face.png b/taiga/base/static/img/emojis/neutral_face.png new file mode 100644 index 000000000..cad1039f0 Binary files /dev/null and b/taiga/base/static/img/emojis/neutral_face.png differ diff --git a/taiga/base/static/img/emojis/new.png b/taiga/base/static/img/emojis/new.png new file mode 100644 index 000000000..363a17586 Binary files /dev/null and b/taiga/base/static/img/emojis/new.png differ diff --git a/taiga/base/static/img/emojis/new_moon.png b/taiga/base/static/img/emojis/new_moon.png new file mode 100644 index 000000000..93859c361 Binary files /dev/null and b/taiga/base/static/img/emojis/new_moon.png differ diff --git a/taiga/base/static/img/emojis/new_moon_with_face.png b/taiga/base/static/img/emojis/new_moon_with_face.png new file mode 100644 index 000000000..3184116d8 Binary files /dev/null and b/taiga/base/static/img/emojis/new_moon_with_face.png differ diff --git a/taiga/base/static/img/emojis/newspaper.png b/taiga/base/static/img/emojis/newspaper.png new file mode 100644 index 000000000..b181864eb Binary files /dev/null and b/taiga/base/static/img/emojis/newspaper.png differ diff --git a/taiga/base/static/img/emojis/ng.png b/taiga/base/static/img/emojis/ng.png new file mode 100644 index 000000000..b1ed5ab08 Binary files /dev/null and b/taiga/base/static/img/emojis/ng.png differ diff --git a/taiga/base/static/img/emojis/nine.png b/taiga/base/static/img/emojis/nine.png new file mode 100644 index 000000000..c7de1bc2f Binary files /dev/null and b/taiga/base/static/img/emojis/nine.png differ diff --git a/taiga/base/static/img/emojis/no_bell.png b/taiga/base/static/img/emojis/no_bell.png new file mode 100644 index 000000000..f8dec782e Binary files /dev/null and b/taiga/base/static/img/emojis/no_bell.png differ diff --git a/taiga/base/static/img/emojis/no_bicycles.png b/taiga/base/static/img/emojis/no_bicycles.png new file mode 100644 index 000000000..f5d714f83 Binary files /dev/null and b/taiga/base/static/img/emojis/no_bicycles.png differ diff --git a/taiga/base/static/img/emojis/no_entry.png b/taiga/base/static/img/emojis/no_entry.png new file mode 100644 index 000000000..62dd70f0d Binary files /dev/null and b/taiga/base/static/img/emojis/no_entry.png differ diff --git a/taiga/base/static/img/emojis/no_entry_sign.png b/taiga/base/static/img/emojis/no_entry_sign.png new file mode 100644 index 000000000..f2e7374bc Binary files /dev/null and b/taiga/base/static/img/emojis/no_entry_sign.png differ diff --git a/taiga/base/static/img/emojis/no_good.png b/taiga/base/static/img/emojis/no_good.png new file mode 100644 index 000000000..6f0e472ae Binary files /dev/null and b/taiga/base/static/img/emojis/no_good.png differ diff --git a/taiga/base/static/img/emojis/no_mobile_phones.png b/taiga/base/static/img/emojis/no_mobile_phones.png new file mode 100644 index 000000000..353e62696 Binary files /dev/null and b/taiga/base/static/img/emojis/no_mobile_phones.png differ diff --git a/taiga/base/static/img/emojis/no_mouth.png b/taiga/base/static/img/emojis/no_mouth.png new file mode 100644 index 000000000..27897a981 Binary files /dev/null and b/taiga/base/static/img/emojis/no_mouth.png differ diff --git a/taiga/base/static/img/emojis/no_pedestrians.png b/taiga/base/static/img/emojis/no_pedestrians.png new file mode 100644 index 000000000..feca9fe80 Binary files /dev/null and b/taiga/base/static/img/emojis/no_pedestrians.png differ diff --git a/taiga/base/static/img/emojis/no_smoking.png b/taiga/base/static/img/emojis/no_smoking.png new file mode 100644 index 000000000..7d0d13eac Binary files /dev/null and b/taiga/base/static/img/emojis/no_smoking.png differ diff --git a/taiga/base/static/img/emojis/non-potable_water.png b/taiga/base/static/img/emojis/non-potable_water.png new file mode 100644 index 000000000..430fa60c9 Binary files /dev/null and b/taiga/base/static/img/emojis/non-potable_water.png differ diff --git a/taiga/base/static/img/emojis/nose.png b/taiga/base/static/img/emojis/nose.png new file mode 100644 index 000000000..567ff49a2 Binary files /dev/null and b/taiga/base/static/img/emojis/nose.png differ diff --git a/taiga/base/static/img/emojis/notebook.png b/taiga/base/static/img/emojis/notebook.png new file mode 100644 index 000000000..554b47609 Binary files /dev/null and b/taiga/base/static/img/emojis/notebook.png differ diff --git a/taiga/base/static/img/emojis/notebook_with_decorative_cover.png b/taiga/base/static/img/emojis/notebook_with_decorative_cover.png new file mode 100644 index 000000000..7c95dae4a Binary files /dev/null and b/taiga/base/static/img/emojis/notebook_with_decorative_cover.png differ diff --git a/taiga/base/static/img/emojis/notes.png b/taiga/base/static/img/emojis/notes.png new file mode 100644 index 000000000..5b072615b Binary files /dev/null and b/taiga/base/static/img/emojis/notes.png differ diff --git a/taiga/base/static/img/emojis/nut_and_bolt.png b/taiga/base/static/img/emojis/nut_and_bolt.png new file mode 100644 index 000000000..dd0f75a18 Binary files /dev/null and b/taiga/base/static/img/emojis/nut_and_bolt.png differ diff --git a/taiga/base/static/img/emojis/o.png b/taiga/base/static/img/emojis/o.png new file mode 100644 index 000000000..2814dd739 Binary files /dev/null and b/taiga/base/static/img/emojis/o.png differ diff --git a/taiga/base/static/img/emojis/o2.png b/taiga/base/static/img/emojis/o2.png new file mode 100644 index 000000000..6377cb134 Binary files /dev/null and b/taiga/base/static/img/emojis/o2.png differ diff --git a/taiga/base/static/img/emojis/ocean.png b/taiga/base/static/img/emojis/ocean.png new file mode 100644 index 000000000..7b0953321 Binary files /dev/null and b/taiga/base/static/img/emojis/ocean.png differ diff --git a/taiga/base/static/img/emojis/octocat.png b/taiga/base/static/img/emojis/octocat.png new file mode 100644 index 000000000..bf699257d Binary files /dev/null and b/taiga/base/static/img/emojis/octocat.png differ diff --git a/taiga/base/static/img/emojis/octopus.png b/taiga/base/static/img/emojis/octopus.png new file mode 100644 index 000000000..1422bf2a6 Binary files /dev/null and b/taiga/base/static/img/emojis/octopus.png differ diff --git a/taiga/base/static/img/emojis/oden.png b/taiga/base/static/img/emojis/oden.png new file mode 100644 index 000000000..49f1200c4 Binary files /dev/null and b/taiga/base/static/img/emojis/oden.png differ diff --git a/taiga/base/static/img/emojis/office.png b/taiga/base/static/img/emojis/office.png new file mode 100644 index 000000000..897cbcc4d Binary files /dev/null and b/taiga/base/static/img/emojis/office.png differ diff --git a/taiga/base/static/img/emojis/ok.png b/taiga/base/static/img/emojis/ok.png new file mode 100644 index 000000000..e53905ade Binary files /dev/null and b/taiga/base/static/img/emojis/ok.png differ diff --git a/taiga/base/static/img/emojis/ok_hand.png b/taiga/base/static/img/emojis/ok_hand.png new file mode 100644 index 000000000..aaa5a873f Binary files /dev/null and b/taiga/base/static/img/emojis/ok_hand.png differ diff --git a/taiga/base/static/img/emojis/ok_woman.png b/taiga/base/static/img/emojis/ok_woman.png new file mode 100644 index 000000000..83a619498 Binary files /dev/null and b/taiga/base/static/img/emojis/ok_woman.png differ diff --git a/taiga/base/static/img/emojis/older_man.png b/taiga/base/static/img/emojis/older_man.png new file mode 100644 index 000000000..723ec0fbe Binary files /dev/null and b/taiga/base/static/img/emojis/older_man.png differ diff --git a/taiga/base/static/img/emojis/older_woman.png b/taiga/base/static/img/emojis/older_woman.png new file mode 100644 index 000000000..379069f02 Binary files /dev/null and b/taiga/base/static/img/emojis/older_woman.png differ diff --git a/taiga/base/static/img/emojis/on.png b/taiga/base/static/img/emojis/on.png new file mode 100644 index 000000000..667baba57 Binary files /dev/null and b/taiga/base/static/img/emojis/on.png differ diff --git a/taiga/base/static/img/emojis/oncoming_automobile.png b/taiga/base/static/img/emojis/oncoming_automobile.png new file mode 100644 index 000000000..9a47ada51 Binary files /dev/null and b/taiga/base/static/img/emojis/oncoming_automobile.png differ diff --git a/taiga/base/static/img/emojis/oncoming_bus.png b/taiga/base/static/img/emojis/oncoming_bus.png new file mode 100644 index 000000000..a0be27a74 Binary files /dev/null and b/taiga/base/static/img/emojis/oncoming_bus.png differ diff --git a/taiga/base/static/img/emojis/oncoming_police_car.png b/taiga/base/static/img/emojis/oncoming_police_car.png new file mode 100644 index 000000000..9e322b4c8 Binary files /dev/null and b/taiga/base/static/img/emojis/oncoming_police_car.png differ diff --git a/taiga/base/static/img/emojis/oncoming_taxi.png b/taiga/base/static/img/emojis/oncoming_taxi.png new file mode 100644 index 000000000..3b2fe37cb Binary files /dev/null and b/taiga/base/static/img/emojis/oncoming_taxi.png differ diff --git a/taiga/base/static/img/emojis/one.png b/taiga/base/static/img/emojis/one.png new file mode 100644 index 000000000..b95c8fec8 Binary files /dev/null and b/taiga/base/static/img/emojis/one.png differ diff --git a/taiga/base/static/img/emojis/open_file_folder.png b/taiga/base/static/img/emojis/open_file_folder.png new file mode 100644 index 000000000..efc3943f5 Binary files /dev/null and b/taiga/base/static/img/emojis/open_file_folder.png differ diff --git a/taiga/base/static/img/emojis/open_hands.png b/taiga/base/static/img/emojis/open_hands.png new file mode 100644 index 000000000..3a99a3c8b Binary files /dev/null and b/taiga/base/static/img/emojis/open_hands.png differ diff --git a/taiga/base/static/img/emojis/open_mouth.png b/taiga/base/static/img/emojis/open_mouth.png new file mode 100644 index 000000000..203d4668f Binary files /dev/null and b/taiga/base/static/img/emojis/open_mouth.png differ diff --git a/taiga/base/static/img/emojis/ophiuchus.png b/taiga/base/static/img/emojis/ophiuchus.png new file mode 100644 index 000000000..10e6372a3 Binary files /dev/null and b/taiga/base/static/img/emojis/ophiuchus.png differ diff --git a/taiga/base/static/img/emojis/orange_book.png b/taiga/base/static/img/emojis/orange_book.png new file mode 100644 index 000000000..18fa5f839 Binary files /dev/null and b/taiga/base/static/img/emojis/orange_book.png differ diff --git a/taiga/base/static/img/emojis/outbox_tray.png b/taiga/base/static/img/emojis/outbox_tray.png new file mode 100644 index 000000000..50e7788f5 Binary files /dev/null and b/taiga/base/static/img/emojis/outbox_tray.png differ diff --git a/taiga/base/static/img/emojis/ox.png b/taiga/base/static/img/emojis/ox.png new file mode 100644 index 000000000..901276d87 Binary files /dev/null and b/taiga/base/static/img/emojis/ox.png differ diff --git a/taiga/base/static/img/emojis/page_facing_up.png b/taiga/base/static/img/emojis/page_facing_up.png new file mode 100644 index 000000000..870673256 Binary files /dev/null and b/taiga/base/static/img/emojis/page_facing_up.png differ diff --git a/taiga/base/static/img/emojis/page_with_curl.png b/taiga/base/static/img/emojis/page_with_curl.png new file mode 100644 index 000000000..9c78559ce Binary files /dev/null and b/taiga/base/static/img/emojis/page_with_curl.png differ diff --git a/taiga/base/static/img/emojis/pager.png b/taiga/base/static/img/emojis/pager.png new file mode 100644 index 000000000..514e06fe0 Binary files /dev/null and b/taiga/base/static/img/emojis/pager.png differ diff --git a/taiga/base/static/img/emojis/palm_tree.png b/taiga/base/static/img/emojis/palm_tree.png new file mode 100644 index 000000000..b0c6728cb Binary files /dev/null and b/taiga/base/static/img/emojis/palm_tree.png differ diff --git a/taiga/base/static/img/emojis/panda_face.png b/taiga/base/static/img/emojis/panda_face.png new file mode 100644 index 000000000..f8e350afa Binary files /dev/null and b/taiga/base/static/img/emojis/panda_face.png differ diff --git a/taiga/base/static/img/emojis/paperclip.png b/taiga/base/static/img/emojis/paperclip.png new file mode 100644 index 000000000..c58f78790 Binary files /dev/null and b/taiga/base/static/img/emojis/paperclip.png differ diff --git a/taiga/base/static/img/emojis/parking.png b/taiga/base/static/img/emojis/parking.png new file mode 100644 index 000000000..7f5244c8e Binary files /dev/null and b/taiga/base/static/img/emojis/parking.png differ diff --git a/taiga/base/static/img/emojis/part_alternation_mark.png b/taiga/base/static/img/emojis/part_alternation_mark.png new file mode 100644 index 000000000..51ebc42a9 Binary files /dev/null and b/taiga/base/static/img/emojis/part_alternation_mark.png differ diff --git a/taiga/base/static/img/emojis/partly_sunny.png b/taiga/base/static/img/emojis/partly_sunny.png new file mode 100644 index 000000000..f64b86195 Binary files /dev/null and b/taiga/base/static/img/emojis/partly_sunny.png differ diff --git a/taiga/base/static/img/emojis/passport_control.png b/taiga/base/static/img/emojis/passport_control.png new file mode 100644 index 000000000..be212c09f Binary files /dev/null and b/taiga/base/static/img/emojis/passport_control.png differ diff --git a/taiga/base/static/img/emojis/paw_prints.png b/taiga/base/static/img/emojis/paw_prints.png new file mode 100644 index 000000000..bbefe6b08 Binary files /dev/null and b/taiga/base/static/img/emojis/paw_prints.png differ diff --git a/taiga/base/static/img/emojis/peach.png b/taiga/base/static/img/emojis/peach.png new file mode 100644 index 000000000..b38ffa122 Binary files /dev/null and b/taiga/base/static/img/emojis/peach.png differ diff --git a/taiga/base/static/img/emojis/pear.png b/taiga/base/static/img/emojis/pear.png new file mode 100644 index 000000000..b4bda4732 Binary files /dev/null and b/taiga/base/static/img/emojis/pear.png differ diff --git a/taiga/base/static/img/emojis/pencil.png b/taiga/base/static/img/emojis/pencil.png new file mode 100644 index 000000000..e0e7b956d Binary files /dev/null and b/taiga/base/static/img/emojis/pencil.png differ diff --git a/taiga/base/static/img/emojis/pencil2.png b/taiga/base/static/img/emojis/pencil2.png new file mode 100644 index 000000000..15f056c12 Binary files /dev/null and b/taiga/base/static/img/emojis/pencil2.png differ diff --git a/taiga/base/static/img/emojis/penguin.png b/taiga/base/static/img/emojis/penguin.png new file mode 100644 index 000000000..269246852 Binary files /dev/null and b/taiga/base/static/img/emojis/penguin.png differ diff --git a/taiga/base/static/img/emojis/pensive.png b/taiga/base/static/img/emojis/pensive.png new file mode 100644 index 000000000..91834a9e4 Binary files /dev/null and b/taiga/base/static/img/emojis/pensive.png differ diff --git a/taiga/base/static/img/emojis/performing_arts.png b/taiga/base/static/img/emojis/performing_arts.png new file mode 100644 index 000000000..79b62aba9 Binary files /dev/null and b/taiga/base/static/img/emojis/performing_arts.png differ diff --git a/taiga/base/static/img/emojis/persevere.png b/taiga/base/static/img/emojis/persevere.png new file mode 100644 index 000000000..e6546a5ef Binary files /dev/null and b/taiga/base/static/img/emojis/persevere.png differ diff --git a/taiga/base/static/img/emojis/person_frowning.png b/taiga/base/static/img/emojis/person_frowning.png new file mode 100644 index 000000000..0147f35a5 Binary files /dev/null and b/taiga/base/static/img/emojis/person_frowning.png differ diff --git a/taiga/base/static/img/emojis/person_with_blond_hair.png b/taiga/base/static/img/emojis/person_with_blond_hair.png new file mode 100644 index 000000000..6a39504d1 Binary files /dev/null and b/taiga/base/static/img/emojis/person_with_blond_hair.png differ diff --git a/taiga/base/static/img/emojis/person_with_pouting_face.png b/taiga/base/static/img/emojis/person_with_pouting_face.png new file mode 100644 index 000000000..2f5dc2ea3 Binary files /dev/null and b/taiga/base/static/img/emojis/person_with_pouting_face.png differ diff --git a/taiga/base/static/img/emojis/phone.png b/taiga/base/static/img/emojis/phone.png new file mode 100644 index 000000000..ce0bc5dfe Binary files /dev/null and b/taiga/base/static/img/emojis/phone.png differ diff --git a/taiga/base/static/img/emojis/pig.png b/taiga/base/static/img/emojis/pig.png new file mode 100644 index 000000000..1170a54e3 Binary files /dev/null and b/taiga/base/static/img/emojis/pig.png differ diff --git a/taiga/base/static/img/emojis/pig2.png b/taiga/base/static/img/emojis/pig2.png new file mode 100644 index 000000000..d041aaf49 Binary files /dev/null and b/taiga/base/static/img/emojis/pig2.png differ diff --git a/taiga/base/static/img/emojis/pig_nose.png b/taiga/base/static/img/emojis/pig_nose.png new file mode 100644 index 000000000..6b1a3ed9c Binary files /dev/null and b/taiga/base/static/img/emojis/pig_nose.png differ diff --git a/taiga/base/static/img/emojis/pill.png b/taiga/base/static/img/emojis/pill.png new file mode 100644 index 000000000..8bb7d6af6 Binary files /dev/null and b/taiga/base/static/img/emojis/pill.png differ diff --git a/taiga/base/static/img/emojis/pineapple.png b/taiga/base/static/img/emojis/pineapple.png new file mode 100644 index 000000000..23b8790b6 Binary files /dev/null and b/taiga/base/static/img/emojis/pineapple.png differ diff --git a/taiga/base/static/img/emojis/pisces.png b/taiga/base/static/img/emojis/pisces.png new file mode 100644 index 000000000..994faa242 Binary files /dev/null and b/taiga/base/static/img/emojis/pisces.png differ diff --git a/taiga/base/static/img/emojis/pizza.png b/taiga/base/static/img/emojis/pizza.png new file mode 100644 index 000000000..7748847fe Binary files /dev/null and b/taiga/base/static/img/emojis/pizza.png differ diff --git a/taiga/base/static/img/emojis/plus1.png b/taiga/base/static/img/emojis/plus1.png new file mode 100644 index 000000000..be57e612c Binary files /dev/null and b/taiga/base/static/img/emojis/plus1.png differ diff --git a/taiga/base/static/img/emojis/point_down.png b/taiga/base/static/img/emojis/point_down.png new file mode 100644 index 000000000..2cdab2c3e Binary files /dev/null and b/taiga/base/static/img/emojis/point_down.png differ diff --git a/taiga/base/static/img/emojis/point_left.png b/taiga/base/static/img/emojis/point_left.png new file mode 100644 index 000000000..31e10d215 Binary files /dev/null and b/taiga/base/static/img/emojis/point_left.png differ diff --git a/taiga/base/static/img/emojis/point_right.png b/taiga/base/static/img/emojis/point_right.png new file mode 100644 index 000000000..702ca9f75 Binary files /dev/null and b/taiga/base/static/img/emojis/point_right.png differ diff --git a/taiga/base/static/img/emojis/point_up.png b/taiga/base/static/img/emojis/point_up.png new file mode 100644 index 000000000..cae59446a Binary files /dev/null and b/taiga/base/static/img/emojis/point_up.png differ diff --git a/taiga/base/static/img/emojis/point_up_2.png b/taiga/base/static/img/emojis/point_up_2.png new file mode 100644 index 000000000..91ad18831 Binary files /dev/null and b/taiga/base/static/img/emojis/point_up_2.png differ diff --git a/taiga/base/static/img/emojis/police_car.png b/taiga/base/static/img/emojis/police_car.png new file mode 100644 index 000000000..120f17e3a Binary files /dev/null and b/taiga/base/static/img/emojis/police_car.png differ diff --git a/taiga/base/static/img/emojis/poodle.png b/taiga/base/static/img/emojis/poodle.png new file mode 100644 index 000000000..334812dd3 Binary files /dev/null and b/taiga/base/static/img/emojis/poodle.png differ diff --git a/taiga/base/static/img/emojis/poop.png b/taiga/base/static/img/emojis/poop.png new file mode 100644 index 000000000..cbd8d1853 Binary files /dev/null and b/taiga/base/static/img/emojis/poop.png differ diff --git a/taiga/base/static/img/emojis/post_office.png b/taiga/base/static/img/emojis/post_office.png new file mode 100644 index 000000000..504b4c6d6 Binary files /dev/null and b/taiga/base/static/img/emojis/post_office.png differ diff --git a/taiga/base/static/img/emojis/postal_horn.png b/taiga/base/static/img/emojis/postal_horn.png new file mode 100644 index 000000000..75f64cd53 Binary files /dev/null and b/taiga/base/static/img/emojis/postal_horn.png differ diff --git a/taiga/base/static/img/emojis/postbox.png b/taiga/base/static/img/emojis/postbox.png new file mode 100644 index 000000000..44e441c19 Binary files /dev/null and b/taiga/base/static/img/emojis/postbox.png differ diff --git a/taiga/base/static/img/emojis/potable_water.png b/taiga/base/static/img/emojis/potable_water.png new file mode 100644 index 000000000..5ab2446b7 Binary files /dev/null and b/taiga/base/static/img/emojis/potable_water.png differ diff --git a/taiga/base/static/img/emojis/pouch.png b/taiga/base/static/img/emojis/pouch.png new file mode 100644 index 000000000..9a172784e Binary files /dev/null and b/taiga/base/static/img/emojis/pouch.png differ diff --git a/taiga/base/static/img/emojis/poultry_leg.png b/taiga/base/static/img/emojis/poultry_leg.png new file mode 100644 index 000000000..912b2e36f Binary files /dev/null and b/taiga/base/static/img/emojis/poultry_leg.png differ diff --git a/taiga/base/static/img/emojis/pound.png b/taiga/base/static/img/emojis/pound.png new file mode 100644 index 000000000..0c9a61339 Binary files /dev/null and b/taiga/base/static/img/emojis/pound.png differ diff --git a/taiga/base/static/img/emojis/pouting_cat.png b/taiga/base/static/img/emojis/pouting_cat.png new file mode 100644 index 000000000..b595de7ba Binary files /dev/null and b/taiga/base/static/img/emojis/pouting_cat.png differ diff --git a/taiga/base/static/img/emojis/pray.png b/taiga/base/static/img/emojis/pray.png new file mode 100644 index 000000000..c6fb57f86 Binary files /dev/null and b/taiga/base/static/img/emojis/pray.png differ diff --git a/taiga/base/static/img/emojis/princess.png b/taiga/base/static/img/emojis/princess.png new file mode 100644 index 000000000..6ffb80055 Binary files /dev/null and b/taiga/base/static/img/emojis/princess.png differ diff --git a/taiga/base/static/img/emojis/punch.png b/taiga/base/static/img/emojis/punch.png new file mode 100644 index 000000000..f817ca3fb Binary files /dev/null and b/taiga/base/static/img/emojis/punch.png differ diff --git a/taiga/base/static/img/emojis/purple_heart.png b/taiga/base/static/img/emojis/purple_heart.png new file mode 100644 index 000000000..b146cb89e Binary files /dev/null and b/taiga/base/static/img/emojis/purple_heart.png differ diff --git a/taiga/base/static/img/emojis/purse.png b/taiga/base/static/img/emojis/purse.png new file mode 100644 index 000000000..28f687793 Binary files /dev/null and b/taiga/base/static/img/emojis/purse.png differ diff --git a/taiga/base/static/img/emojis/pushpin.png b/taiga/base/static/img/emojis/pushpin.png new file mode 100644 index 000000000..68b05b072 Binary files /dev/null and b/taiga/base/static/img/emojis/pushpin.png differ diff --git a/taiga/base/static/img/emojis/put_litter_in_its_place.png b/taiga/base/static/img/emojis/put_litter_in_its_place.png new file mode 100644 index 000000000..bafb8fb4e Binary files /dev/null and b/taiga/base/static/img/emojis/put_litter_in_its_place.png differ diff --git a/taiga/base/static/img/emojis/question.png b/taiga/base/static/img/emojis/question.png new file mode 100644 index 000000000..eeaa3026f Binary files /dev/null and b/taiga/base/static/img/emojis/question.png differ diff --git a/taiga/base/static/img/emojis/rabbit.png b/taiga/base/static/img/emojis/rabbit.png new file mode 100644 index 000000000..e85cd6cd2 Binary files /dev/null and b/taiga/base/static/img/emojis/rabbit.png differ diff --git a/taiga/base/static/img/emojis/rabbit2.png b/taiga/base/static/img/emojis/rabbit2.png new file mode 100644 index 000000000..69cf71ea4 Binary files /dev/null and b/taiga/base/static/img/emojis/rabbit2.png differ diff --git a/taiga/base/static/img/emojis/racehorse.png b/taiga/base/static/img/emojis/racehorse.png new file mode 100644 index 000000000..0e773c991 Binary files /dev/null and b/taiga/base/static/img/emojis/racehorse.png differ diff --git a/taiga/base/static/img/emojis/radio.png b/taiga/base/static/img/emojis/radio.png new file mode 100644 index 000000000..65c5b881c Binary files /dev/null and b/taiga/base/static/img/emojis/radio.png differ diff --git a/taiga/base/static/img/emojis/radio_button.png b/taiga/base/static/img/emojis/radio_button.png new file mode 100644 index 000000000..379b0bc5a Binary files /dev/null and b/taiga/base/static/img/emojis/radio_button.png differ diff --git a/taiga/base/static/img/emojis/rage.png b/taiga/base/static/img/emojis/rage.png new file mode 100644 index 000000000..3d6468aac Binary files /dev/null and b/taiga/base/static/img/emojis/rage.png differ diff --git a/taiga/base/static/img/emojis/rage1.png b/taiga/base/static/img/emojis/rage1.png new file mode 100644 index 000000000..9ad4d9ee4 Binary files /dev/null and b/taiga/base/static/img/emojis/rage1.png differ diff --git a/taiga/base/static/img/emojis/rage2.png b/taiga/base/static/img/emojis/rage2.png new file mode 100644 index 000000000..4d8adb2ba Binary files /dev/null and b/taiga/base/static/img/emojis/rage2.png differ diff --git a/taiga/base/static/img/emojis/rage3.png b/taiga/base/static/img/emojis/rage3.png new file mode 100644 index 000000000..2061f992e Binary files /dev/null and b/taiga/base/static/img/emojis/rage3.png differ diff --git a/taiga/base/static/img/emojis/rage4.png b/taiga/base/static/img/emojis/rage4.png new file mode 100644 index 000000000..769816f28 Binary files /dev/null and b/taiga/base/static/img/emojis/rage4.png differ diff --git a/taiga/base/static/img/emojis/railway_car.png b/taiga/base/static/img/emojis/railway_car.png new file mode 100644 index 000000000..48a1c44e8 Binary files /dev/null and b/taiga/base/static/img/emojis/railway_car.png differ diff --git a/taiga/base/static/img/emojis/rainbow.png b/taiga/base/static/img/emojis/rainbow.png new file mode 100644 index 000000000..21cb89c8f Binary files /dev/null and b/taiga/base/static/img/emojis/rainbow.png differ diff --git a/taiga/base/static/img/emojis/raised_hand.png b/taiga/base/static/img/emojis/raised_hand.png new file mode 100644 index 000000000..7537b3750 Binary files /dev/null and b/taiga/base/static/img/emojis/raised_hand.png differ diff --git a/taiga/base/static/img/emojis/raised_hands.png b/taiga/base/static/img/emojis/raised_hands.png new file mode 100644 index 000000000..47843f0b0 Binary files /dev/null and b/taiga/base/static/img/emojis/raised_hands.png differ diff --git a/taiga/base/static/img/emojis/raising_hand.png b/taiga/base/static/img/emojis/raising_hand.png new file mode 100644 index 000000000..bd35abc5f Binary files /dev/null and b/taiga/base/static/img/emojis/raising_hand.png differ diff --git a/taiga/base/static/img/emojis/ram.png b/taiga/base/static/img/emojis/ram.png new file mode 100644 index 000000000..e6ba49f0f Binary files /dev/null and b/taiga/base/static/img/emojis/ram.png differ diff --git a/taiga/base/static/img/emojis/ramen.png b/taiga/base/static/img/emojis/ramen.png new file mode 100644 index 000000000..03a81fa85 Binary files /dev/null and b/taiga/base/static/img/emojis/ramen.png differ diff --git a/taiga/base/static/img/emojis/rat.png b/taiga/base/static/img/emojis/rat.png new file mode 100644 index 000000000..30905b34c Binary files /dev/null and b/taiga/base/static/img/emojis/rat.png differ diff --git a/taiga/base/static/img/emojis/recycle.png b/taiga/base/static/img/emojis/recycle.png new file mode 100644 index 000000000..4f5929108 Binary files /dev/null and b/taiga/base/static/img/emojis/recycle.png differ diff --git a/taiga/base/static/img/emojis/red_car.png b/taiga/base/static/img/emojis/red_car.png new file mode 100644 index 000000000..31393f7fe Binary files /dev/null and b/taiga/base/static/img/emojis/red_car.png differ diff --git a/taiga/base/static/img/emojis/red_circle.png b/taiga/base/static/img/emojis/red_circle.png new file mode 100644 index 000000000..cafcb6eaa Binary files /dev/null and b/taiga/base/static/img/emojis/red_circle.png differ diff --git a/taiga/base/static/img/emojis/registered.png b/taiga/base/static/img/emojis/registered.png new file mode 100644 index 000000000..8eaa0bd8d Binary files /dev/null and b/taiga/base/static/img/emojis/registered.png differ diff --git a/taiga/base/static/img/emojis/relaxed.png b/taiga/base/static/img/emojis/relaxed.png new file mode 100644 index 000000000..c69f82dc9 Binary files /dev/null and b/taiga/base/static/img/emojis/relaxed.png differ diff --git a/taiga/base/static/img/emojis/relieved.png b/taiga/base/static/img/emojis/relieved.png new file mode 100644 index 000000000..cfe42c53d Binary files /dev/null and b/taiga/base/static/img/emojis/relieved.png differ diff --git a/taiga/base/static/img/emojis/repeat.png b/taiga/base/static/img/emojis/repeat.png new file mode 100644 index 000000000..e20fdaa84 Binary files /dev/null and b/taiga/base/static/img/emojis/repeat.png differ diff --git a/taiga/base/static/img/emojis/repeat_one.png b/taiga/base/static/img/emojis/repeat_one.png new file mode 100644 index 000000000..e192e0e83 Binary files /dev/null and b/taiga/base/static/img/emojis/repeat_one.png differ diff --git a/taiga/base/static/img/emojis/restroom.png b/taiga/base/static/img/emojis/restroom.png new file mode 100644 index 000000000..74a0335d8 Binary files /dev/null and b/taiga/base/static/img/emojis/restroom.png differ diff --git a/taiga/base/static/img/emojis/revolving_hearts.png b/taiga/base/static/img/emojis/revolving_hearts.png new file mode 100644 index 000000000..82506b5a1 Binary files /dev/null and b/taiga/base/static/img/emojis/revolving_hearts.png differ diff --git a/taiga/base/static/img/emojis/rewind.png b/taiga/base/static/img/emojis/rewind.png new file mode 100644 index 000000000..a699e83b1 Binary files /dev/null and b/taiga/base/static/img/emojis/rewind.png differ diff --git a/taiga/base/static/img/emojis/ribbon.png b/taiga/base/static/img/emojis/ribbon.png new file mode 100644 index 000000000..bf4e85cb6 Binary files /dev/null and b/taiga/base/static/img/emojis/ribbon.png differ diff --git a/taiga/base/static/img/emojis/rice.png b/taiga/base/static/img/emojis/rice.png new file mode 100644 index 000000000..4c941d87e Binary files /dev/null and b/taiga/base/static/img/emojis/rice.png differ diff --git a/taiga/base/static/img/emojis/rice_ball.png b/taiga/base/static/img/emojis/rice_ball.png new file mode 100644 index 000000000..923ecf541 Binary files /dev/null and b/taiga/base/static/img/emojis/rice_ball.png differ diff --git a/taiga/base/static/img/emojis/rice_cracker.png b/taiga/base/static/img/emojis/rice_cracker.png new file mode 100644 index 000000000..cbb5ce7f2 Binary files /dev/null and b/taiga/base/static/img/emojis/rice_cracker.png differ diff --git a/taiga/base/static/img/emojis/rice_scene.png b/taiga/base/static/img/emojis/rice_scene.png new file mode 100644 index 000000000..edb7b3aba Binary files /dev/null and b/taiga/base/static/img/emojis/rice_scene.png differ diff --git a/taiga/base/static/img/emojis/ring.png b/taiga/base/static/img/emojis/ring.png new file mode 100644 index 000000000..5e0daaa25 Binary files /dev/null and b/taiga/base/static/img/emojis/ring.png differ diff --git a/taiga/base/static/img/emojis/rocket.png b/taiga/base/static/img/emojis/rocket.png new file mode 100644 index 000000000..5bfdfa3c9 Binary files /dev/null and b/taiga/base/static/img/emojis/rocket.png differ diff --git a/taiga/base/static/img/emojis/roller_coaster.png b/taiga/base/static/img/emojis/roller_coaster.png new file mode 100644 index 000000000..9ad1f8d94 Binary files /dev/null and b/taiga/base/static/img/emojis/roller_coaster.png differ diff --git a/taiga/base/static/img/emojis/rooster.png b/taiga/base/static/img/emojis/rooster.png new file mode 100644 index 000000000..f57aaa552 Binary files /dev/null and b/taiga/base/static/img/emojis/rooster.png differ diff --git a/taiga/base/static/img/emojis/rose.png b/taiga/base/static/img/emojis/rose.png new file mode 100644 index 000000000..2c9eeb000 Binary files /dev/null and b/taiga/base/static/img/emojis/rose.png differ diff --git a/taiga/base/static/img/emojis/rotating_light.png b/taiga/base/static/img/emojis/rotating_light.png new file mode 100644 index 000000000..2615880fe Binary files /dev/null and b/taiga/base/static/img/emojis/rotating_light.png differ diff --git a/taiga/base/static/img/emojis/round_pushpin.png b/taiga/base/static/img/emojis/round_pushpin.png new file mode 100644 index 000000000..68777d7f2 Binary files /dev/null and b/taiga/base/static/img/emojis/round_pushpin.png differ diff --git a/taiga/base/static/img/emojis/rowboat.png b/taiga/base/static/img/emojis/rowboat.png new file mode 100644 index 000000000..9179acbd7 Binary files /dev/null and b/taiga/base/static/img/emojis/rowboat.png differ diff --git a/taiga/base/static/img/emojis/ru.png b/taiga/base/static/img/emojis/ru.png new file mode 100644 index 000000000..55a132827 Binary files /dev/null and b/taiga/base/static/img/emojis/ru.png differ diff --git a/taiga/base/static/img/emojis/rugby_football.png b/taiga/base/static/img/emojis/rugby_football.png new file mode 100644 index 000000000..a574841ed Binary files /dev/null and b/taiga/base/static/img/emojis/rugby_football.png differ diff --git a/taiga/base/static/img/emojis/runner.png b/taiga/base/static/img/emojis/runner.png new file mode 100644 index 000000000..5a804cc5d Binary files /dev/null and b/taiga/base/static/img/emojis/runner.png differ diff --git a/taiga/base/static/img/emojis/running.png b/taiga/base/static/img/emojis/running.png new file mode 100644 index 000000000..5a804cc5d Binary files /dev/null and b/taiga/base/static/img/emojis/running.png differ diff --git a/taiga/base/static/img/emojis/running_shirt_with_sash.png b/taiga/base/static/img/emojis/running_shirt_with_sash.png new file mode 100644 index 000000000..8f8d63db4 Binary files /dev/null and b/taiga/base/static/img/emojis/running_shirt_with_sash.png differ diff --git a/taiga/base/static/img/emojis/sa.png b/taiga/base/static/img/emojis/sa.png new file mode 100644 index 000000000..01d634215 Binary files /dev/null and b/taiga/base/static/img/emojis/sa.png differ diff --git a/taiga/base/static/img/emojis/sagittarius.png b/taiga/base/static/img/emojis/sagittarius.png new file mode 100644 index 000000000..ac9a122ab Binary files /dev/null and b/taiga/base/static/img/emojis/sagittarius.png differ diff --git a/taiga/base/static/img/emojis/sailboat.png b/taiga/base/static/img/emojis/sailboat.png new file mode 100644 index 000000000..ab65ccc40 Binary files /dev/null and b/taiga/base/static/img/emojis/sailboat.png differ diff --git a/taiga/base/static/img/emojis/sake.png b/taiga/base/static/img/emojis/sake.png new file mode 100644 index 000000000..54e05a06d Binary files /dev/null and b/taiga/base/static/img/emojis/sake.png differ diff --git a/taiga/base/static/img/emojis/sandal.png b/taiga/base/static/img/emojis/sandal.png new file mode 100644 index 000000000..d5b8f3b09 Binary files /dev/null and b/taiga/base/static/img/emojis/sandal.png differ diff --git a/taiga/base/static/img/emojis/santa.png b/taiga/base/static/img/emojis/santa.png new file mode 100644 index 000000000..2155ed7d6 Binary files /dev/null and b/taiga/base/static/img/emojis/santa.png differ diff --git a/taiga/base/static/img/emojis/satellite.png b/taiga/base/static/img/emojis/satellite.png new file mode 100644 index 000000000..0f9c5285f Binary files /dev/null and b/taiga/base/static/img/emojis/satellite.png differ diff --git a/taiga/base/static/img/emojis/satisfied.png b/taiga/base/static/img/emojis/satisfied.png new file mode 100644 index 000000000..5f0703b33 Binary files /dev/null and b/taiga/base/static/img/emojis/satisfied.png differ diff --git a/taiga/base/static/img/emojis/saxophone.png b/taiga/base/static/img/emojis/saxophone.png new file mode 100644 index 000000000..70b7f5547 Binary files /dev/null and b/taiga/base/static/img/emojis/saxophone.png differ diff --git a/taiga/base/static/img/emojis/school.png b/taiga/base/static/img/emojis/school.png new file mode 100644 index 000000000..6f5d062f9 Binary files /dev/null and b/taiga/base/static/img/emojis/school.png differ diff --git a/taiga/base/static/img/emojis/school_satchel.png b/taiga/base/static/img/emojis/school_satchel.png new file mode 100644 index 000000000..2eb54d4e4 Binary files /dev/null and b/taiga/base/static/img/emojis/school_satchel.png differ diff --git a/taiga/base/static/img/emojis/scissors.png b/taiga/base/static/img/emojis/scissors.png new file mode 100644 index 000000000..d8178c798 Binary files /dev/null and b/taiga/base/static/img/emojis/scissors.png differ diff --git a/taiga/base/static/img/emojis/scorpius.png b/taiga/base/static/img/emojis/scorpius.png new file mode 100644 index 000000000..4285976b7 Binary files /dev/null and b/taiga/base/static/img/emojis/scorpius.png differ diff --git a/taiga/base/static/img/emojis/scream.png b/taiga/base/static/img/emojis/scream.png new file mode 100644 index 000000000..e565dd14b Binary files /dev/null and b/taiga/base/static/img/emojis/scream.png differ diff --git a/taiga/base/static/img/emojis/scream_cat.png b/taiga/base/static/img/emojis/scream_cat.png new file mode 100644 index 000000000..5d9688e4c Binary files /dev/null and b/taiga/base/static/img/emojis/scream_cat.png differ diff --git a/taiga/base/static/img/emojis/scroll.png b/taiga/base/static/img/emojis/scroll.png new file mode 100644 index 000000000..82aaf9722 Binary files /dev/null and b/taiga/base/static/img/emojis/scroll.png differ diff --git a/taiga/base/static/img/emojis/seat.png b/taiga/base/static/img/emojis/seat.png new file mode 100644 index 000000000..652387e01 Binary files /dev/null and b/taiga/base/static/img/emojis/seat.png differ diff --git a/taiga/base/static/img/emojis/secret.png b/taiga/base/static/img/emojis/secret.png new file mode 100644 index 000000000..e0cedfa54 Binary files /dev/null and b/taiga/base/static/img/emojis/secret.png differ diff --git a/taiga/base/static/img/emojis/see_no_evil.png b/taiga/base/static/img/emojis/see_no_evil.png new file mode 100644 index 000000000..8f287532c Binary files /dev/null and b/taiga/base/static/img/emojis/see_no_evil.png differ diff --git a/taiga/base/static/img/emojis/seedling.png b/taiga/base/static/img/emojis/seedling.png new file mode 100644 index 000000000..741f0c75f Binary files /dev/null and b/taiga/base/static/img/emojis/seedling.png differ diff --git a/taiga/base/static/img/emojis/seven.png b/taiga/base/static/img/emojis/seven.png new file mode 100644 index 000000000..5be9b6e94 Binary files /dev/null and b/taiga/base/static/img/emojis/seven.png differ diff --git a/taiga/base/static/img/emojis/shaved_ice.png b/taiga/base/static/img/emojis/shaved_ice.png new file mode 100644 index 000000000..c19c52c81 Binary files /dev/null and b/taiga/base/static/img/emojis/shaved_ice.png differ diff --git a/taiga/base/static/img/emojis/sheep.png b/taiga/base/static/img/emojis/sheep.png new file mode 100644 index 000000000..10e36725c Binary files /dev/null and b/taiga/base/static/img/emojis/sheep.png differ diff --git a/taiga/base/static/img/emojis/shell.png b/taiga/base/static/img/emojis/shell.png new file mode 100644 index 000000000..88654d831 Binary files /dev/null and b/taiga/base/static/img/emojis/shell.png differ diff --git a/taiga/base/static/img/emojis/ship.png b/taiga/base/static/img/emojis/ship.png new file mode 100644 index 000000000..086ba04d4 Binary files /dev/null and b/taiga/base/static/img/emojis/ship.png differ diff --git a/taiga/base/static/img/emojis/shipit.png b/taiga/base/static/img/emojis/shipit.png new file mode 100644 index 000000000..452bdeebe Binary files /dev/null and b/taiga/base/static/img/emojis/shipit.png differ diff --git a/taiga/base/static/img/emojis/shirt.png b/taiga/base/static/img/emojis/shirt.png new file mode 100644 index 000000000..d725451a6 Binary files /dev/null and b/taiga/base/static/img/emojis/shirt.png differ diff --git a/taiga/base/static/img/emojis/shit.png b/taiga/base/static/img/emojis/shit.png new file mode 100644 index 000000000..cbd8d1853 Binary files /dev/null and b/taiga/base/static/img/emojis/shit.png differ diff --git a/taiga/base/static/img/emojis/shoe.png b/taiga/base/static/img/emojis/shoe.png new file mode 100644 index 000000000..f24be573c Binary files /dev/null and b/taiga/base/static/img/emojis/shoe.png differ diff --git a/taiga/base/static/img/emojis/shower.png b/taiga/base/static/img/emojis/shower.png new file mode 100644 index 000000000..3c1fcdd76 Binary files /dev/null and b/taiga/base/static/img/emojis/shower.png differ diff --git a/taiga/base/static/img/emojis/signal_strength.png b/taiga/base/static/img/emojis/signal_strength.png new file mode 100644 index 000000000..187811372 Binary files /dev/null and b/taiga/base/static/img/emojis/signal_strength.png differ diff --git a/taiga/base/static/img/emojis/six.png b/taiga/base/static/img/emojis/six.png new file mode 100644 index 000000000..cd099b3ea Binary files /dev/null and b/taiga/base/static/img/emojis/six.png differ diff --git a/taiga/base/static/img/emojis/six_pointed_star.png b/taiga/base/static/img/emojis/six_pointed_star.png new file mode 100644 index 000000000..2dcdf017c Binary files /dev/null and b/taiga/base/static/img/emojis/six_pointed_star.png differ diff --git a/taiga/base/static/img/emojis/ski.png b/taiga/base/static/img/emojis/ski.png new file mode 100644 index 000000000..f7f1b756c Binary files /dev/null and b/taiga/base/static/img/emojis/ski.png differ diff --git a/taiga/base/static/img/emojis/skull.png b/taiga/base/static/img/emojis/skull.png new file mode 100644 index 000000000..948580419 Binary files /dev/null and b/taiga/base/static/img/emojis/skull.png differ diff --git a/taiga/base/static/img/emojis/sleeping.png b/taiga/base/static/img/emojis/sleeping.png new file mode 100644 index 000000000..98fe02f9a Binary files /dev/null and b/taiga/base/static/img/emojis/sleeping.png differ diff --git a/taiga/base/static/img/emojis/sleepy.png b/taiga/base/static/img/emojis/sleepy.png new file mode 100644 index 000000000..c980161bb Binary files /dev/null and b/taiga/base/static/img/emojis/sleepy.png differ diff --git a/taiga/base/static/img/emojis/slot_machine.png b/taiga/base/static/img/emojis/slot_machine.png new file mode 100644 index 000000000..eed006623 Binary files /dev/null and b/taiga/base/static/img/emojis/slot_machine.png differ diff --git a/taiga/base/static/img/emojis/small_blue_diamond.png b/taiga/base/static/img/emojis/small_blue_diamond.png new file mode 100644 index 000000000..cb75ddc1c Binary files /dev/null and b/taiga/base/static/img/emojis/small_blue_diamond.png differ diff --git a/taiga/base/static/img/emojis/small_orange_diamond.png b/taiga/base/static/img/emojis/small_orange_diamond.png new file mode 100644 index 000000000..6fc3e29dc Binary files /dev/null and b/taiga/base/static/img/emojis/small_orange_diamond.png differ diff --git a/taiga/base/static/img/emojis/small_red_triangle.png b/taiga/base/static/img/emojis/small_red_triangle.png new file mode 100644 index 000000000..088bb465b Binary files /dev/null and b/taiga/base/static/img/emojis/small_red_triangle.png differ diff --git a/taiga/base/static/img/emojis/small_red_triangle_down.png b/taiga/base/static/img/emojis/small_red_triangle_down.png new file mode 100644 index 000000000..4ef9f008d Binary files /dev/null and b/taiga/base/static/img/emojis/small_red_triangle_down.png differ diff --git a/taiga/base/static/img/emojis/smile.png b/taiga/base/static/img/emojis/smile.png new file mode 100644 index 000000000..aaa5fd363 Binary files /dev/null and b/taiga/base/static/img/emojis/smile.png differ diff --git a/taiga/base/static/img/emojis/smile_cat.png b/taiga/base/static/img/emojis/smile_cat.png new file mode 100644 index 000000000..7e9a474fd Binary files /dev/null and b/taiga/base/static/img/emojis/smile_cat.png differ diff --git a/taiga/base/static/img/emojis/smiley.png b/taiga/base/static/img/emojis/smiley.png new file mode 100644 index 000000000..1d46b561a Binary files /dev/null and b/taiga/base/static/img/emojis/smiley.png differ diff --git a/taiga/base/static/img/emojis/smiley_cat.png b/taiga/base/static/img/emojis/smiley_cat.png new file mode 100644 index 000000000..b1e376719 Binary files /dev/null and b/taiga/base/static/img/emojis/smiley_cat.png differ diff --git a/taiga/base/static/img/emojis/smiling_imp.png b/taiga/base/static/img/emojis/smiling_imp.png new file mode 100644 index 000000000..b19174b61 Binary files /dev/null and b/taiga/base/static/img/emojis/smiling_imp.png differ diff --git a/taiga/base/static/img/emojis/smirk.png b/taiga/base/static/img/emojis/smirk.png new file mode 100644 index 000000000..c8c2860bb Binary files /dev/null and b/taiga/base/static/img/emojis/smirk.png differ diff --git a/taiga/base/static/img/emojis/smirk_cat.png b/taiga/base/static/img/emojis/smirk_cat.png new file mode 100644 index 000000000..67f18db7f Binary files /dev/null and b/taiga/base/static/img/emojis/smirk_cat.png differ diff --git a/taiga/base/static/img/emojis/smoking.png b/taiga/base/static/img/emojis/smoking.png new file mode 100644 index 000000000..c3cbcacea Binary files /dev/null and b/taiga/base/static/img/emojis/smoking.png differ diff --git a/taiga/base/static/img/emojis/snail.png b/taiga/base/static/img/emojis/snail.png new file mode 100644 index 000000000..86c289d2a Binary files /dev/null and b/taiga/base/static/img/emojis/snail.png differ diff --git a/taiga/base/static/img/emojis/snake.png b/taiga/base/static/img/emojis/snake.png new file mode 100644 index 000000000..00189648e Binary files /dev/null and b/taiga/base/static/img/emojis/snake.png differ diff --git a/taiga/base/static/img/emojis/snowboarder.png b/taiga/base/static/img/emojis/snowboarder.png new file mode 100644 index 000000000..1768b72d7 Binary files /dev/null and b/taiga/base/static/img/emojis/snowboarder.png differ diff --git a/taiga/base/static/img/emojis/snowflake.png b/taiga/base/static/img/emojis/snowflake.png new file mode 100644 index 000000000..80ee0a963 Binary files /dev/null and b/taiga/base/static/img/emojis/snowflake.png differ diff --git a/taiga/base/static/img/emojis/snowman.png b/taiga/base/static/img/emojis/snowman.png new file mode 100644 index 000000000..d11a05b85 Binary files /dev/null and b/taiga/base/static/img/emojis/snowman.png differ diff --git a/taiga/base/static/img/emojis/sob.png b/taiga/base/static/img/emojis/sob.png new file mode 100644 index 000000000..88f0c5dae Binary files /dev/null and b/taiga/base/static/img/emojis/sob.png differ diff --git a/taiga/base/static/img/emojis/soccer.png b/taiga/base/static/img/emojis/soccer.png new file mode 100644 index 000000000..da80057c4 Binary files /dev/null and b/taiga/base/static/img/emojis/soccer.png differ diff --git a/taiga/base/static/img/emojis/soon.png b/taiga/base/static/img/emojis/soon.png new file mode 100644 index 000000000..fc8703b9d Binary files /dev/null and b/taiga/base/static/img/emojis/soon.png differ diff --git a/taiga/base/static/img/emojis/sos.png b/taiga/base/static/img/emojis/sos.png new file mode 100644 index 000000000..cf4008543 Binary files /dev/null and b/taiga/base/static/img/emojis/sos.png differ diff --git a/taiga/base/static/img/emojis/sound.png b/taiga/base/static/img/emojis/sound.png new file mode 100644 index 000000000..bff0b9215 Binary files /dev/null and b/taiga/base/static/img/emojis/sound.png differ diff --git a/taiga/base/static/img/emojis/space_invader.png b/taiga/base/static/img/emojis/space_invader.png new file mode 100644 index 000000000..d93fa4c91 Binary files /dev/null and b/taiga/base/static/img/emojis/space_invader.png differ diff --git a/taiga/base/static/img/emojis/spades.png b/taiga/base/static/img/emojis/spades.png new file mode 100644 index 000000000..f630a8fb3 Binary files /dev/null and b/taiga/base/static/img/emojis/spades.png differ diff --git a/taiga/base/static/img/emojis/spaghetti.png b/taiga/base/static/img/emojis/spaghetti.png new file mode 100644 index 000000000..bd555df7e Binary files /dev/null and b/taiga/base/static/img/emojis/spaghetti.png differ diff --git a/taiga/base/static/img/emojis/sparkler.png b/taiga/base/static/img/emojis/sparkler.png new file mode 100644 index 000000000..eea06365d Binary files /dev/null and b/taiga/base/static/img/emojis/sparkler.png differ diff --git a/taiga/base/static/img/emojis/sparkles.png b/taiga/base/static/img/emojis/sparkles.png new file mode 100644 index 000000000..ece420235 Binary files /dev/null and b/taiga/base/static/img/emojis/sparkles.png differ diff --git a/taiga/base/static/img/emojis/sparkling_heart.png b/taiga/base/static/img/emojis/sparkling_heart.png new file mode 100644 index 000000000..b5e8d1244 Binary files /dev/null and b/taiga/base/static/img/emojis/sparkling_heart.png differ diff --git a/taiga/base/static/img/emojis/speak_no_evil.png b/taiga/base/static/img/emojis/speak_no_evil.png new file mode 100644 index 000000000..d9a2086ee Binary files /dev/null and b/taiga/base/static/img/emojis/speak_no_evil.png differ diff --git a/taiga/base/static/img/emojis/speaker.png b/taiga/base/static/img/emojis/speaker.png new file mode 100644 index 000000000..4c9cd48bf Binary files /dev/null and b/taiga/base/static/img/emojis/speaker.png differ diff --git a/taiga/base/static/img/emojis/speech_balloon.png b/taiga/base/static/img/emojis/speech_balloon.png new file mode 100644 index 000000000..13b663ec8 Binary files /dev/null and b/taiga/base/static/img/emojis/speech_balloon.png differ diff --git a/taiga/base/static/img/emojis/speedboat.png b/taiga/base/static/img/emojis/speedboat.png new file mode 100644 index 000000000..68b677ebb Binary files /dev/null and b/taiga/base/static/img/emojis/speedboat.png differ diff --git a/taiga/base/static/img/emojis/squirrel.png b/taiga/base/static/img/emojis/squirrel.png new file mode 100644 index 000000000..452bdeebe Binary files /dev/null and b/taiga/base/static/img/emojis/squirrel.png differ diff --git a/taiga/base/static/img/emojis/star.png b/taiga/base/static/img/emojis/star.png new file mode 100644 index 000000000..e1e48b06e Binary files /dev/null and b/taiga/base/static/img/emojis/star.png differ diff --git a/taiga/base/static/img/emojis/star2.png b/taiga/base/static/img/emojis/star2.png new file mode 100644 index 000000000..75343a6cb Binary files /dev/null and b/taiga/base/static/img/emojis/star2.png differ diff --git a/taiga/base/static/img/emojis/stars.png b/taiga/base/static/img/emojis/stars.png new file mode 100644 index 000000000..ee318114b Binary files /dev/null and b/taiga/base/static/img/emojis/stars.png differ diff --git a/taiga/base/static/img/emojis/station.png b/taiga/base/static/img/emojis/station.png new file mode 100644 index 000000000..26eb13363 Binary files /dev/null and b/taiga/base/static/img/emojis/station.png differ diff --git a/taiga/base/static/img/emojis/statue_of_liberty.png b/taiga/base/static/img/emojis/statue_of_liberty.png new file mode 100644 index 000000000..bf5395a0e Binary files /dev/null and b/taiga/base/static/img/emojis/statue_of_liberty.png differ diff --git a/taiga/base/static/img/emojis/steam_locomotive.png b/taiga/base/static/img/emojis/steam_locomotive.png new file mode 100644 index 000000000..f5224d848 Binary files /dev/null and b/taiga/base/static/img/emojis/steam_locomotive.png differ diff --git a/taiga/base/static/img/emojis/stew.png b/taiga/base/static/img/emojis/stew.png new file mode 100644 index 000000000..9d0e58f5c Binary files /dev/null and b/taiga/base/static/img/emojis/stew.png differ diff --git a/taiga/base/static/img/emojis/straight_ruler.png b/taiga/base/static/img/emojis/straight_ruler.png new file mode 100644 index 000000000..88bbfb176 Binary files /dev/null and b/taiga/base/static/img/emojis/straight_ruler.png differ diff --git a/taiga/base/static/img/emojis/strawberry.png b/taiga/base/static/img/emojis/strawberry.png new file mode 100644 index 000000000..9f746b52f Binary files /dev/null and b/taiga/base/static/img/emojis/strawberry.png differ diff --git a/taiga/base/static/img/emojis/stuck_out_tongue.png b/taiga/base/static/img/emojis/stuck_out_tongue.png new file mode 100644 index 000000000..e093225c2 Binary files /dev/null and b/taiga/base/static/img/emojis/stuck_out_tongue.png differ diff --git a/taiga/base/static/img/emojis/stuck_out_tongue_closed_eyes.png b/taiga/base/static/img/emojis/stuck_out_tongue_closed_eyes.png new file mode 100644 index 000000000..585978b3f Binary files /dev/null and b/taiga/base/static/img/emojis/stuck_out_tongue_closed_eyes.png differ diff --git a/taiga/base/static/img/emojis/stuck_out_tongue_winking_eye.png b/taiga/base/static/img/emojis/stuck_out_tongue_winking_eye.png new file mode 100644 index 000000000..2d325fddc Binary files /dev/null and b/taiga/base/static/img/emojis/stuck_out_tongue_winking_eye.png differ diff --git a/taiga/base/static/img/emojis/sun_with_face.png b/taiga/base/static/img/emojis/sun_with_face.png new file mode 100644 index 000000000..d72115b27 Binary files /dev/null and b/taiga/base/static/img/emojis/sun_with_face.png differ diff --git a/taiga/base/static/img/emojis/sunflower.png b/taiga/base/static/img/emojis/sunflower.png new file mode 100644 index 000000000..5bdd66e57 Binary files /dev/null and b/taiga/base/static/img/emojis/sunflower.png differ diff --git a/taiga/base/static/img/emojis/sunglasses.png b/taiga/base/static/img/emojis/sunglasses.png new file mode 100644 index 000000000..0def8c43e Binary files /dev/null and b/taiga/base/static/img/emojis/sunglasses.png differ diff --git a/taiga/base/static/img/emojis/sunny.png b/taiga/base/static/img/emojis/sunny.png new file mode 100644 index 000000000..504eab734 Binary files /dev/null and b/taiga/base/static/img/emojis/sunny.png differ diff --git a/taiga/base/static/img/emojis/sunrise.png b/taiga/base/static/img/emojis/sunrise.png new file mode 100644 index 000000000..e4906c7fd Binary files /dev/null and b/taiga/base/static/img/emojis/sunrise.png differ diff --git a/taiga/base/static/img/emojis/sunrise_over_mountains.png b/taiga/base/static/img/emojis/sunrise_over_mountains.png new file mode 100644 index 000000000..05e34bb3e Binary files /dev/null and b/taiga/base/static/img/emojis/sunrise_over_mountains.png differ diff --git a/taiga/base/static/img/emojis/surfer.png b/taiga/base/static/img/emojis/surfer.png new file mode 100644 index 000000000..237730d11 Binary files /dev/null and b/taiga/base/static/img/emojis/surfer.png differ diff --git a/taiga/base/static/img/emojis/sushi.png b/taiga/base/static/img/emojis/sushi.png new file mode 100644 index 000000000..29b29b914 Binary files /dev/null and b/taiga/base/static/img/emojis/sushi.png differ diff --git a/taiga/base/static/img/emojis/suspect.png b/taiga/base/static/img/emojis/suspect.png new file mode 100644 index 000000000..ef1e7e3bd Binary files /dev/null and b/taiga/base/static/img/emojis/suspect.png differ diff --git a/taiga/base/static/img/emojis/suspension_railway.png b/taiga/base/static/img/emojis/suspension_railway.png new file mode 100644 index 000000000..e18d9f082 Binary files /dev/null and b/taiga/base/static/img/emojis/suspension_railway.png differ diff --git a/taiga/base/static/img/emojis/sweat.png b/taiga/base/static/img/emojis/sweat.png new file mode 100644 index 000000000..93cfa91bf Binary files /dev/null and b/taiga/base/static/img/emojis/sweat.png differ diff --git a/taiga/base/static/img/emojis/sweat_drops.png b/taiga/base/static/img/emojis/sweat_drops.png new file mode 100644 index 000000000..8e8827104 Binary files /dev/null and b/taiga/base/static/img/emojis/sweat_drops.png differ diff --git a/taiga/base/static/img/emojis/sweat_smile.png b/taiga/base/static/img/emojis/sweat_smile.png new file mode 100644 index 000000000..85cab9207 Binary files /dev/null and b/taiga/base/static/img/emojis/sweat_smile.png differ diff --git a/taiga/base/static/img/emojis/sweet_potato.png b/taiga/base/static/img/emojis/sweet_potato.png new file mode 100644 index 000000000..7f8ebb39f Binary files /dev/null and b/taiga/base/static/img/emojis/sweet_potato.png differ diff --git a/taiga/base/static/img/emojis/swimmer.png b/taiga/base/static/img/emojis/swimmer.png new file mode 100644 index 000000000..dc1b98ab6 Binary files /dev/null and b/taiga/base/static/img/emojis/swimmer.png differ diff --git a/taiga/base/static/img/emojis/symbols.png b/taiga/base/static/img/emojis/symbols.png new file mode 100644 index 000000000..f49fda06d Binary files /dev/null and b/taiga/base/static/img/emojis/symbols.png differ diff --git a/taiga/base/static/img/emojis/syringe.png b/taiga/base/static/img/emojis/syringe.png new file mode 100644 index 000000000..f2183abbb Binary files /dev/null and b/taiga/base/static/img/emojis/syringe.png differ diff --git a/taiga/base/static/img/emojis/tada.png b/taiga/base/static/img/emojis/tada.png new file mode 100644 index 000000000..7c4227969 Binary files /dev/null and b/taiga/base/static/img/emojis/tada.png differ diff --git a/taiga/base/static/img/emojis/tanabata_tree.png b/taiga/base/static/img/emojis/tanabata_tree.png new file mode 100644 index 000000000..df64983db Binary files /dev/null and b/taiga/base/static/img/emojis/tanabata_tree.png differ diff --git a/taiga/base/static/img/emojis/tangerine.png b/taiga/base/static/img/emojis/tangerine.png new file mode 100644 index 000000000..4c71ea83a Binary files /dev/null and b/taiga/base/static/img/emojis/tangerine.png differ diff --git a/taiga/base/static/img/emojis/taurus.png b/taiga/base/static/img/emojis/taurus.png new file mode 100644 index 000000000..b5c22c420 Binary files /dev/null and b/taiga/base/static/img/emojis/taurus.png differ diff --git a/taiga/base/static/img/emojis/taxi.png b/taiga/base/static/img/emojis/taxi.png new file mode 100644 index 000000000..467e5e53a Binary files /dev/null and b/taiga/base/static/img/emojis/taxi.png differ diff --git a/taiga/base/static/img/emojis/tea.png b/taiga/base/static/img/emojis/tea.png new file mode 100644 index 000000000..1e55c9b5e Binary files /dev/null and b/taiga/base/static/img/emojis/tea.png differ diff --git a/taiga/base/static/img/emojis/telephone.png b/taiga/base/static/img/emojis/telephone.png new file mode 100644 index 000000000..ce0bc5dfe Binary files /dev/null and b/taiga/base/static/img/emojis/telephone.png differ diff --git a/taiga/base/static/img/emojis/telephone_receiver.png b/taiga/base/static/img/emojis/telephone_receiver.png new file mode 100644 index 000000000..a620eb459 Binary files /dev/null and b/taiga/base/static/img/emojis/telephone_receiver.png differ diff --git a/taiga/base/static/img/emojis/telescope.png b/taiga/base/static/img/emojis/telescope.png new file mode 100644 index 000000000..da6a45f84 Binary files /dev/null and b/taiga/base/static/img/emojis/telescope.png differ diff --git a/taiga/base/static/img/emojis/tennis.png b/taiga/base/static/img/emojis/tennis.png new file mode 100644 index 000000000..e27e7f142 Binary files /dev/null and b/taiga/base/static/img/emojis/tennis.png differ diff --git a/taiga/base/static/img/emojis/tent.png b/taiga/base/static/img/emojis/tent.png new file mode 100644 index 000000000..64b7a7098 Binary files /dev/null and b/taiga/base/static/img/emojis/tent.png differ diff --git a/taiga/base/static/img/emojis/thought_balloon.png b/taiga/base/static/img/emojis/thought_balloon.png new file mode 100644 index 000000000..fc6fd0fce Binary files /dev/null and b/taiga/base/static/img/emojis/thought_balloon.png differ diff --git a/taiga/base/static/img/emojis/three.png b/taiga/base/static/img/emojis/three.png new file mode 100644 index 000000000..514ed48a7 Binary files /dev/null and b/taiga/base/static/img/emojis/three.png differ diff --git a/taiga/base/static/img/emojis/thumbsdown.png b/taiga/base/static/img/emojis/thumbsdown.png new file mode 100644 index 000000000..32c794435 Binary files /dev/null and b/taiga/base/static/img/emojis/thumbsdown.png differ diff --git a/taiga/base/static/img/emojis/thumbsup.png b/taiga/base/static/img/emojis/thumbsup.png new file mode 100644 index 000000000..be57e612c Binary files /dev/null and b/taiga/base/static/img/emojis/thumbsup.png differ diff --git a/taiga/base/static/img/emojis/ticket.png b/taiga/base/static/img/emojis/ticket.png new file mode 100644 index 000000000..e98577a43 Binary files /dev/null and b/taiga/base/static/img/emojis/ticket.png differ diff --git a/taiga/base/static/img/emojis/tiger.png b/taiga/base/static/img/emojis/tiger.png new file mode 100644 index 000000000..0b29a50e2 Binary files /dev/null and b/taiga/base/static/img/emojis/tiger.png differ diff --git a/taiga/base/static/img/emojis/tiger2.png b/taiga/base/static/img/emojis/tiger2.png new file mode 100644 index 000000000..4e34d507f Binary files /dev/null and b/taiga/base/static/img/emojis/tiger2.png differ diff --git a/taiga/base/static/img/emojis/tired_face.png b/taiga/base/static/img/emojis/tired_face.png new file mode 100644 index 000000000..df0dfce76 Binary files /dev/null and b/taiga/base/static/img/emojis/tired_face.png differ diff --git a/taiga/base/static/img/emojis/tm.png b/taiga/base/static/img/emojis/tm.png new file mode 100644 index 000000000..fd01dbf89 Binary files /dev/null and b/taiga/base/static/img/emojis/tm.png differ diff --git a/taiga/base/static/img/emojis/toilet.png b/taiga/base/static/img/emojis/toilet.png new file mode 100644 index 000000000..8daa95471 Binary files /dev/null and b/taiga/base/static/img/emojis/toilet.png differ diff --git a/taiga/base/static/img/emojis/tokyo_tower.png b/taiga/base/static/img/emojis/tokyo_tower.png new file mode 100644 index 000000000..f07851832 Binary files /dev/null and b/taiga/base/static/img/emojis/tokyo_tower.png differ diff --git a/taiga/base/static/img/emojis/tomato.png b/taiga/base/static/img/emojis/tomato.png new file mode 100644 index 000000000..bfb666ff0 Binary files /dev/null and b/taiga/base/static/img/emojis/tomato.png differ diff --git a/taiga/base/static/img/emojis/tongue.png b/taiga/base/static/img/emojis/tongue.png new file mode 100644 index 000000000..94b8b4b7f Binary files /dev/null and b/taiga/base/static/img/emojis/tongue.png differ diff --git a/taiga/base/static/img/emojis/top.png b/taiga/base/static/img/emojis/top.png new file mode 100644 index 000000000..060488ddc Binary files /dev/null and b/taiga/base/static/img/emojis/top.png differ diff --git a/taiga/base/static/img/emojis/tophat.png b/taiga/base/static/img/emojis/tophat.png new file mode 100644 index 000000000..12ce77295 Binary files /dev/null and b/taiga/base/static/img/emojis/tophat.png differ diff --git a/taiga/base/static/img/emojis/tractor.png b/taiga/base/static/img/emojis/tractor.png new file mode 100644 index 000000000..1eac3e92c Binary files /dev/null and b/taiga/base/static/img/emojis/tractor.png differ diff --git a/taiga/base/static/img/emojis/traffic_light.png b/taiga/base/static/img/emojis/traffic_light.png new file mode 100644 index 000000000..c6f289b99 Binary files /dev/null and b/taiga/base/static/img/emojis/traffic_light.png differ diff --git a/taiga/base/static/img/emojis/train.png b/taiga/base/static/img/emojis/train.png new file mode 100644 index 000000000..67c6a8491 Binary files /dev/null and b/taiga/base/static/img/emojis/train.png differ diff --git a/taiga/base/static/img/emojis/train2.png b/taiga/base/static/img/emojis/train2.png new file mode 100644 index 000000000..d94482e18 Binary files /dev/null and b/taiga/base/static/img/emojis/train2.png differ diff --git a/taiga/base/static/img/emojis/tram.png b/taiga/base/static/img/emojis/tram.png new file mode 100644 index 000000000..cb58a058b Binary files /dev/null and b/taiga/base/static/img/emojis/tram.png differ diff --git a/taiga/base/static/img/emojis/triangular_flag_on_post.png b/taiga/base/static/img/emojis/triangular_flag_on_post.png new file mode 100644 index 000000000..c62b930c2 Binary files /dev/null and b/taiga/base/static/img/emojis/triangular_flag_on_post.png differ diff --git a/taiga/base/static/img/emojis/triangular_ruler.png b/taiga/base/static/img/emojis/triangular_ruler.png new file mode 100644 index 000000000..18ce44cc2 Binary files /dev/null and b/taiga/base/static/img/emojis/triangular_ruler.png differ diff --git a/taiga/base/static/img/emojis/trident.png b/taiga/base/static/img/emojis/trident.png new file mode 100644 index 000000000..a9fbcca7e Binary files /dev/null and b/taiga/base/static/img/emojis/trident.png differ diff --git a/taiga/base/static/img/emojis/triumph.png b/taiga/base/static/img/emojis/triumph.png new file mode 100644 index 000000000..e333f8826 Binary files /dev/null and b/taiga/base/static/img/emojis/triumph.png differ diff --git a/taiga/base/static/img/emojis/trolleybus.png b/taiga/base/static/img/emojis/trolleybus.png new file mode 100644 index 000000000..040fe2549 Binary files /dev/null and b/taiga/base/static/img/emojis/trolleybus.png differ diff --git a/taiga/base/static/img/emojis/trollface.png b/taiga/base/static/img/emojis/trollface.png new file mode 100644 index 000000000..c8ef48b37 Binary files /dev/null and b/taiga/base/static/img/emojis/trollface.png differ diff --git a/taiga/base/static/img/emojis/trophy.png b/taiga/base/static/img/emojis/trophy.png new file mode 100644 index 000000000..f2e3d6779 Binary files /dev/null and b/taiga/base/static/img/emojis/trophy.png differ diff --git a/taiga/base/static/img/emojis/tropical_drink.png b/taiga/base/static/img/emojis/tropical_drink.png new file mode 100644 index 000000000..7ecf24679 Binary files /dev/null and b/taiga/base/static/img/emojis/tropical_drink.png differ diff --git a/taiga/base/static/img/emojis/tropical_fish.png b/taiga/base/static/img/emojis/tropical_fish.png new file mode 100644 index 000000000..989efecc9 Binary files /dev/null and b/taiga/base/static/img/emojis/tropical_fish.png differ diff --git a/taiga/base/static/img/emojis/truck.png b/taiga/base/static/img/emojis/truck.png new file mode 100644 index 000000000..9f730469b Binary files /dev/null and b/taiga/base/static/img/emojis/truck.png differ diff --git a/taiga/base/static/img/emojis/trumpet.png b/taiga/base/static/img/emojis/trumpet.png new file mode 100644 index 000000000..4411b6e9a Binary files /dev/null and b/taiga/base/static/img/emojis/trumpet.png differ diff --git a/taiga/base/static/img/emojis/tshirt.png b/taiga/base/static/img/emojis/tshirt.png new file mode 100644 index 000000000..d725451a6 Binary files /dev/null and b/taiga/base/static/img/emojis/tshirt.png differ diff --git a/taiga/base/static/img/emojis/tulip.png b/taiga/base/static/img/emojis/tulip.png new file mode 100644 index 000000000..57707d3a4 Binary files /dev/null and b/taiga/base/static/img/emojis/tulip.png differ diff --git a/taiga/base/static/img/emojis/turtle.png b/taiga/base/static/img/emojis/turtle.png new file mode 100644 index 000000000..eef5a7c32 Binary files /dev/null and b/taiga/base/static/img/emojis/turtle.png differ diff --git a/taiga/base/static/img/emojis/tv.png b/taiga/base/static/img/emojis/tv.png new file mode 100644 index 000000000..9be28a244 Binary files /dev/null and b/taiga/base/static/img/emojis/tv.png differ diff --git a/taiga/base/static/img/emojis/twisted_rightwards_arrows.png b/taiga/base/static/img/emojis/twisted_rightwards_arrows.png new file mode 100644 index 000000000..215fe1bd6 Binary files /dev/null and b/taiga/base/static/img/emojis/twisted_rightwards_arrows.png differ diff --git a/taiga/base/static/img/emojis/two.png b/taiga/base/static/img/emojis/two.png new file mode 100644 index 000000000..81cda08e9 Binary files /dev/null and b/taiga/base/static/img/emojis/two.png differ diff --git a/taiga/base/static/img/emojis/two_hearts.png b/taiga/base/static/img/emojis/two_hearts.png new file mode 100644 index 000000000..06b07ffc2 Binary files /dev/null and b/taiga/base/static/img/emojis/two_hearts.png differ diff --git a/taiga/base/static/img/emojis/two_men_holding_hands.png b/taiga/base/static/img/emojis/two_men_holding_hands.png new file mode 100644 index 000000000..3571952fb Binary files /dev/null and b/taiga/base/static/img/emojis/two_men_holding_hands.png differ diff --git a/taiga/base/static/img/emojis/two_women_holding_hands.png b/taiga/base/static/img/emojis/two_women_holding_hands.png new file mode 100644 index 000000000..50b18107d Binary files /dev/null and b/taiga/base/static/img/emojis/two_women_holding_hands.png differ diff --git a/taiga/base/static/img/emojis/u5272.png b/taiga/base/static/img/emojis/u5272.png new file mode 100644 index 000000000..c8655f0d2 Binary files /dev/null and b/taiga/base/static/img/emojis/u5272.png differ diff --git a/taiga/base/static/img/emojis/u5408.png b/taiga/base/static/img/emojis/u5408.png new file mode 100644 index 000000000..0372587a8 Binary files /dev/null and b/taiga/base/static/img/emojis/u5408.png differ diff --git a/taiga/base/static/img/emojis/u55b6.png b/taiga/base/static/img/emojis/u55b6.png new file mode 100644 index 000000000..0ff799e4f Binary files /dev/null and b/taiga/base/static/img/emojis/u55b6.png differ diff --git a/taiga/base/static/img/emojis/u6307.png b/taiga/base/static/img/emojis/u6307.png new file mode 100644 index 000000000..8c1943fa1 Binary files /dev/null and b/taiga/base/static/img/emojis/u6307.png differ diff --git a/taiga/base/static/img/emojis/u6708.png b/taiga/base/static/img/emojis/u6708.png new file mode 100644 index 000000000..749417b74 Binary files /dev/null and b/taiga/base/static/img/emojis/u6708.png differ diff --git a/taiga/base/static/img/emojis/u6709.png b/taiga/base/static/img/emojis/u6709.png new file mode 100644 index 000000000..5c7ef0b6e Binary files /dev/null and b/taiga/base/static/img/emojis/u6709.png differ diff --git a/taiga/base/static/img/emojis/u6e80.png b/taiga/base/static/img/emojis/u6e80.png new file mode 100644 index 000000000..25b0a3faf Binary files /dev/null and b/taiga/base/static/img/emojis/u6e80.png differ diff --git a/taiga/base/static/img/emojis/u7121.png b/taiga/base/static/img/emojis/u7121.png new file mode 100644 index 000000000..205b28ecd Binary files /dev/null and b/taiga/base/static/img/emojis/u7121.png differ diff --git a/taiga/base/static/img/emojis/u7533.png b/taiga/base/static/img/emojis/u7533.png new file mode 100644 index 000000000..465980488 Binary files /dev/null and b/taiga/base/static/img/emojis/u7533.png differ diff --git a/taiga/base/static/img/emojis/u7981.png b/taiga/base/static/img/emojis/u7981.png new file mode 100644 index 000000000..ec44c5f8a Binary files /dev/null and b/taiga/base/static/img/emojis/u7981.png differ diff --git a/taiga/base/static/img/emojis/u7a7a.png b/taiga/base/static/img/emojis/u7a7a.png new file mode 100644 index 000000000..9c11bc5ba Binary files /dev/null and b/taiga/base/static/img/emojis/u7a7a.png differ diff --git a/taiga/base/static/img/emojis/uk.png b/taiga/base/static/img/emojis/uk.png new file mode 100644 index 000000000..025535495 Binary files /dev/null and b/taiga/base/static/img/emojis/uk.png differ diff --git a/taiga/base/static/img/emojis/umbrella.png b/taiga/base/static/img/emojis/umbrella.png new file mode 100644 index 000000000..14946f656 Binary files /dev/null and b/taiga/base/static/img/emojis/umbrella.png differ diff --git a/taiga/base/static/img/emojis/unamused.png b/taiga/base/static/img/emojis/unamused.png new file mode 100644 index 000000000..4c92ac5f9 Binary files /dev/null and b/taiga/base/static/img/emojis/unamused.png differ diff --git a/taiga/base/static/img/emojis/underage.png b/taiga/base/static/img/emojis/underage.png new file mode 100644 index 000000000..93e1a8ec2 Binary files /dev/null and b/taiga/base/static/img/emojis/underage.png differ diff --git a/taiga/base/static/img/emojis/unlock.png b/taiga/base/static/img/emojis/unlock.png new file mode 100644 index 000000000..891aa4abf Binary files /dev/null and b/taiga/base/static/img/emojis/unlock.png differ diff --git a/taiga/base/static/img/emojis/up.png b/taiga/base/static/img/emojis/up.png new file mode 100644 index 000000000..a07225c71 Binary files /dev/null and b/taiga/base/static/img/emojis/up.png differ diff --git a/taiga/base/static/img/emojis/us.png b/taiga/base/static/img/emojis/us.png new file mode 100644 index 000000000..e7d07308a Binary files /dev/null and b/taiga/base/static/img/emojis/us.png differ diff --git a/taiga/base/static/img/emojis/v.png b/taiga/base/static/img/emojis/v.png new file mode 100644 index 000000000..59478c7a7 Binary files /dev/null and b/taiga/base/static/img/emojis/v.png differ diff --git a/taiga/base/static/img/emojis/vertical_traffic_light.png b/taiga/base/static/img/emojis/vertical_traffic_light.png new file mode 100644 index 000000000..d2035fd88 Binary files /dev/null and b/taiga/base/static/img/emojis/vertical_traffic_light.png differ diff --git a/taiga/base/static/img/emojis/vhs.png b/taiga/base/static/img/emojis/vhs.png new file mode 100644 index 000000000..e43f425f0 Binary files /dev/null and b/taiga/base/static/img/emojis/vhs.png differ diff --git a/taiga/base/static/img/emojis/vibration_mode.png b/taiga/base/static/img/emojis/vibration_mode.png new file mode 100644 index 000000000..71b0d1677 Binary files /dev/null and b/taiga/base/static/img/emojis/vibration_mode.png differ diff --git a/taiga/base/static/img/emojis/video_camera.png b/taiga/base/static/img/emojis/video_camera.png new file mode 100644 index 000000000..64b118a44 Binary files /dev/null and b/taiga/base/static/img/emojis/video_camera.png differ diff --git a/taiga/base/static/img/emojis/video_game.png b/taiga/base/static/img/emojis/video_game.png new file mode 100644 index 000000000..1a599e7ce Binary files /dev/null and b/taiga/base/static/img/emojis/video_game.png differ diff --git a/taiga/base/static/img/emojis/violin.png b/taiga/base/static/img/emojis/violin.png new file mode 100644 index 000000000..83313bb59 Binary files /dev/null and b/taiga/base/static/img/emojis/violin.png differ diff --git a/taiga/base/static/img/emojis/virgo.png b/taiga/base/static/img/emojis/virgo.png new file mode 100644 index 000000000..467cd5f90 Binary files /dev/null and b/taiga/base/static/img/emojis/virgo.png differ diff --git a/taiga/base/static/img/emojis/volcano.png b/taiga/base/static/img/emojis/volcano.png new file mode 100644 index 000000000..b4e0819e6 Binary files /dev/null and b/taiga/base/static/img/emojis/volcano.png differ diff --git a/taiga/base/static/img/emojis/vs.png b/taiga/base/static/img/emojis/vs.png new file mode 100644 index 000000000..cdbc3ffaa Binary files /dev/null and b/taiga/base/static/img/emojis/vs.png differ diff --git a/taiga/base/static/img/emojis/walking.png b/taiga/base/static/img/emojis/walking.png new file mode 100644 index 000000000..c26b386fe Binary files /dev/null and b/taiga/base/static/img/emojis/walking.png differ diff --git a/taiga/base/static/img/emojis/waning_crescent_moon.png b/taiga/base/static/img/emojis/waning_crescent_moon.png new file mode 100644 index 000000000..cbf16b7c5 Binary files /dev/null and b/taiga/base/static/img/emojis/waning_crescent_moon.png differ diff --git a/taiga/base/static/img/emojis/waning_gibbous_moon.png b/taiga/base/static/img/emojis/waning_gibbous_moon.png new file mode 100644 index 000000000..40b3827d1 Binary files /dev/null and b/taiga/base/static/img/emojis/waning_gibbous_moon.png differ diff --git a/taiga/base/static/img/emojis/warning.png b/taiga/base/static/img/emojis/warning.png new file mode 100644 index 000000000..69e6ae18e Binary files /dev/null and b/taiga/base/static/img/emojis/warning.png differ diff --git a/taiga/base/static/img/emojis/watch.png b/taiga/base/static/img/emojis/watch.png new file mode 100644 index 000000000..f5e05c6be Binary files /dev/null and b/taiga/base/static/img/emojis/watch.png differ diff --git a/taiga/base/static/img/emojis/water_buffalo.png b/taiga/base/static/img/emojis/water_buffalo.png new file mode 100644 index 000000000..176ec1399 Binary files /dev/null and b/taiga/base/static/img/emojis/water_buffalo.png differ diff --git a/taiga/base/static/img/emojis/watermelon.png b/taiga/base/static/img/emojis/watermelon.png new file mode 100644 index 000000000..79d2db390 Binary files /dev/null and b/taiga/base/static/img/emojis/watermelon.png differ diff --git a/taiga/base/static/img/emojis/wave.png b/taiga/base/static/img/emojis/wave.png new file mode 100644 index 000000000..67bd0affe Binary files /dev/null and b/taiga/base/static/img/emojis/wave.png differ diff --git a/taiga/base/static/img/emojis/wavy_dash.png b/taiga/base/static/img/emojis/wavy_dash.png new file mode 100644 index 000000000..7b862a7b7 Binary files /dev/null and b/taiga/base/static/img/emojis/wavy_dash.png differ diff --git a/taiga/base/static/img/emojis/waxing_crescent_moon.png b/taiga/base/static/img/emojis/waxing_crescent_moon.png new file mode 100644 index 000000000..b7ce6eb56 Binary files /dev/null and b/taiga/base/static/img/emojis/waxing_crescent_moon.png differ diff --git a/taiga/base/static/img/emojis/waxing_gibbous_moon.png b/taiga/base/static/img/emojis/waxing_gibbous_moon.png new file mode 100644 index 000000000..761e8b0e5 Binary files /dev/null and b/taiga/base/static/img/emojis/waxing_gibbous_moon.png differ diff --git a/taiga/base/static/img/emojis/wc.png b/taiga/base/static/img/emojis/wc.png new file mode 100644 index 000000000..c176e0e7c Binary files /dev/null and b/taiga/base/static/img/emojis/wc.png differ diff --git a/taiga/base/static/img/emojis/weary.png b/taiga/base/static/img/emojis/weary.png new file mode 100644 index 000000000..507bf293e Binary files /dev/null and b/taiga/base/static/img/emojis/weary.png differ diff --git a/taiga/base/static/img/emojis/wedding.png b/taiga/base/static/img/emojis/wedding.png new file mode 100644 index 000000000..961623127 Binary files /dev/null and b/taiga/base/static/img/emojis/wedding.png differ diff --git a/taiga/base/static/img/emojis/whale.png b/taiga/base/static/img/emojis/whale.png new file mode 100644 index 000000000..ed0172538 Binary files /dev/null and b/taiga/base/static/img/emojis/whale.png differ diff --git a/taiga/base/static/img/emojis/whale2.png b/taiga/base/static/img/emojis/whale2.png new file mode 100644 index 000000000..9a7bd6208 Binary files /dev/null and b/taiga/base/static/img/emojis/whale2.png differ diff --git a/taiga/base/static/img/emojis/wheelchair.png b/taiga/base/static/img/emojis/wheelchair.png new file mode 100644 index 000000000..708960390 Binary files /dev/null and b/taiga/base/static/img/emojis/wheelchair.png differ diff --git a/taiga/base/static/img/emojis/white_check_mark.png b/taiga/base/static/img/emojis/white_check_mark.png new file mode 100644 index 000000000..92a3d5db1 Binary files /dev/null and b/taiga/base/static/img/emojis/white_check_mark.png differ diff --git a/taiga/base/static/img/emojis/white_circle.png b/taiga/base/static/img/emojis/white_circle.png new file mode 100644 index 000000000..5184662bc Binary files /dev/null and b/taiga/base/static/img/emojis/white_circle.png differ diff --git a/taiga/base/static/img/emojis/white_flower.png b/taiga/base/static/img/emojis/white_flower.png new file mode 100644 index 000000000..9b73257ad Binary files /dev/null and b/taiga/base/static/img/emojis/white_flower.png differ diff --git a/taiga/base/static/img/emojis/white_square.png b/taiga/base/static/img/emojis/white_square.png new file mode 100644 index 000000000..00bcb4052 Binary files /dev/null and b/taiga/base/static/img/emojis/white_square.png differ diff --git a/taiga/base/static/img/emojis/white_square_button.png b/taiga/base/static/img/emojis/white_square_button.png new file mode 100644 index 000000000..c02751f36 Binary files /dev/null and b/taiga/base/static/img/emojis/white_square_button.png differ diff --git a/taiga/base/static/img/emojis/wind_chime.png b/taiga/base/static/img/emojis/wind_chime.png new file mode 100644 index 000000000..4dcab90a6 Binary files /dev/null and b/taiga/base/static/img/emojis/wind_chime.png differ diff --git a/taiga/base/static/img/emojis/wine_glass.png b/taiga/base/static/img/emojis/wine_glass.png new file mode 100644 index 000000000..f5ee0e732 Binary files /dev/null and b/taiga/base/static/img/emojis/wine_glass.png differ diff --git a/taiga/base/static/img/emojis/wink.png b/taiga/base/static/img/emojis/wink.png new file mode 100644 index 000000000..b75959b1b Binary files /dev/null and b/taiga/base/static/img/emojis/wink.png differ diff --git a/taiga/base/static/img/emojis/wolf.png b/taiga/base/static/img/emojis/wolf.png new file mode 100644 index 000000000..0e9564ab6 Binary files /dev/null and b/taiga/base/static/img/emojis/wolf.png differ diff --git a/taiga/base/static/img/emojis/woman.png b/taiga/base/static/img/emojis/woman.png new file mode 100644 index 000000000..3333b504a Binary files /dev/null and b/taiga/base/static/img/emojis/woman.png differ diff --git a/taiga/base/static/img/emojis/womans_clothes.png b/taiga/base/static/img/emojis/womans_clothes.png new file mode 100644 index 000000000..03420fdc6 Binary files /dev/null and b/taiga/base/static/img/emojis/womans_clothes.png differ diff --git a/taiga/base/static/img/emojis/womans_hat.png b/taiga/base/static/img/emojis/womans_hat.png new file mode 100644 index 000000000..2470cb617 Binary files /dev/null and b/taiga/base/static/img/emojis/womans_hat.png differ diff --git a/taiga/base/static/img/emojis/womens.png b/taiga/base/static/img/emojis/womens.png new file mode 100644 index 000000000..35fe2dff7 Binary files /dev/null and b/taiga/base/static/img/emojis/womens.png differ diff --git a/taiga/base/static/img/emojis/worried.png b/taiga/base/static/img/emojis/worried.png new file mode 100644 index 000000000..869755b8f Binary files /dev/null and b/taiga/base/static/img/emojis/worried.png differ diff --git a/taiga/base/static/img/emojis/wrench.png b/taiga/base/static/img/emojis/wrench.png new file mode 100644 index 000000000..422208252 Binary files /dev/null and b/taiga/base/static/img/emojis/wrench.png differ diff --git a/taiga/base/static/img/emojis/x.png b/taiga/base/static/img/emojis/x.png new file mode 100644 index 000000000..50c8b1d5a Binary files /dev/null and b/taiga/base/static/img/emojis/x.png differ diff --git a/taiga/base/static/img/emojis/yellow_heart.png b/taiga/base/static/img/emojis/yellow_heart.png new file mode 100644 index 000000000..47a9449f9 Binary files /dev/null and b/taiga/base/static/img/emojis/yellow_heart.png differ diff --git a/taiga/base/static/img/emojis/yen.png b/taiga/base/static/img/emojis/yen.png new file mode 100644 index 000000000..a241ab42a Binary files /dev/null and b/taiga/base/static/img/emojis/yen.png differ diff --git a/taiga/base/static/img/emojis/yum.png b/taiga/base/static/img/emojis/yum.png new file mode 100644 index 000000000..cb602eee3 Binary files /dev/null and b/taiga/base/static/img/emojis/yum.png differ diff --git a/taiga/base/static/img/emojis/zap.png b/taiga/base/static/img/emojis/zap.png new file mode 100644 index 000000000..af0a95626 Binary files /dev/null and b/taiga/base/static/img/emojis/zap.png differ diff --git a/taiga/base/static/img/emojis/zero.png b/taiga/base/static/img/emojis/zero.png new file mode 100644 index 000000000..f12f2a75e Binary files /dev/null and b/taiga/base/static/img/emojis/zero.png differ diff --git a/taiga/base/static/img/emojis/zzz.png b/taiga/base/static/img/emojis/zzz.png new file mode 100644 index 000000000..edb464c64 Binary files /dev/null and b/taiga/base/static/img/emojis/zzz.png differ diff --git a/taiga/base/status.py b/taiga/base/status.py new file mode 100644 index 000000000..2b30246d0 --- /dev/null +++ b/taiga/base/status.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# The code is partially taken (and modified) from django rest framework +# that is licensed under the following terms: +# +# Copyright (c) 2011-2014, Tom Christie +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +""" +Descriptive HTTP status codes, for code readability. + +See RFC 2616 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html +And RFC 6585 - http://tools.ietf.org/html/rfc6585 +""" + + +def is_informational(code): + return code >= 100 and code <= 199 + +def is_success(code): + return code >= 200 and code <= 299 + +def is_redirect(code): + return code >= 300 and code <= 399 + +def is_client_error(code): + return code >= 400 and code <= 499 + +def is_server_error(code): + return code >= 500 and code <= 599 + + +HTTP_100_CONTINUE = 100 +HTTP_101_SWITCHING_PROTOCOLS = 101 +HTTP_200_OK = 200 +HTTP_201_CREATED = 201 +HTTP_202_ACCEPTED = 202 +HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203 +HTTP_204_NO_CONTENT = 204 +HTTP_205_RESET_CONTENT = 205 +HTTP_206_PARTIAL_CONTENT = 206 +HTTP_300_MULTIPLE_CHOICES = 300 +HTTP_301_MOVED_PERMANENTLY = 301 +HTTP_302_FOUND = 302 +HTTP_303_SEE_OTHER = 303 +HTTP_304_NOT_MODIFIED = 304 +HTTP_305_USE_PROXY = 305 +HTTP_306_RESERVED = 306 +HTTP_307_TEMPORARY_REDIRECT = 307 +HTTP_400_BAD_REQUEST = 400 +HTTP_401_UNAUTHORIZED = 401 +HTTP_402_PAYMENT_REQUIRED = 402 +HTTP_403_FORBIDDEN = 403 +HTTP_404_NOT_FOUND = 404 +HTTP_405_METHOD_NOT_ALLOWED = 405 +HTTP_406_NOT_ACCEPTABLE = 406 +HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407 +HTTP_408_REQUEST_TIMEOUT = 408 +HTTP_409_CONFLICT = 409 +HTTP_410_GONE = 410 +HTTP_411_LENGTH_REQUIRED = 411 +HTTP_412_PRECONDITION_FAILED = 412 +HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413 +HTTP_414_REQUEST_URI_TOO_LONG = 414 +HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 +HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 +HTTP_417_EXPECTATION_FAILED = 417 +HTTP_428_PRECONDITION_REQUIRED = 428 +HTTP_429_TOO_MANY_REQUESTS = 429 +HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 +HTTP_451_BLOCKED = 451 +HTTP_500_INTERNAL_SERVER_ERROR = 500 +HTTP_501_NOT_IMPLEMENTED = 501 +HTTP_502_BAD_GATEWAY = 502 +HTTP_503_SERVICE_UNAVAILABLE = 503 +HTTP_504_GATEWAY_TIMEOUT = 504 +HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505 +HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511 diff --git a/taiga/base/storage.py b/taiga/base/storage.py new file mode 100644 index 000000000..9c40414ab --- /dev/null +++ b/taiga/base/storage.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import errno + +from django.conf import settings +from django.core.files import storage + +import django_sites as sites +import os + +class FileSystemStorage(storage.FileSystemStorage): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if settings.MEDIA_URL.startswith("/"): + site = sites.get_current() + url_tmpl = "{scheme}//{domain}{url}" + scheme = site.scheme and "{0}:".format(site.scheme) or "" + self.base_url = url_tmpl.format(scheme=scheme, domain=site.domain, + url=settings.MEDIA_URL) + + def open(self, name, mode='rb'): + """ + Let's create the needed directory structrue before opening the file + """ + + # Create any intermediate directories that do not exist. + # Note that there is a race between os.path.exists and os.makedirs: + # if os.makedirs fails with EEXIST, the directory was created + # concurrently, and we can continue normally. Refs #16082. + directory = os.path.join(settings.MEDIA_ROOT, os.path.dirname(name)) + if not os.path.exists(directory): + try: + if self.directory_permissions_mode is not None: + # os.makedirs applies the global umask, so we reset it, + # for consistency with file_permissions_mode behavior. + old_umask = os.umask(0) + try: + os.makedirs(directory, self.directory_permissions_mode) + finally: + os.umask(old_umask) + else: + os.makedirs(directory) + except OSError as e: + if e.errno != errno.EEXIST: + raise + if not os.path.isdir(directory): + raise IOError("%s exists and is not a directory." % directory) + + return super().open(name, mode=mode) diff --git a/taiga/base/templates/emails/base-body-html.jinja b/taiga/base/templates/emails/base-body-html.jinja new file mode 100644 index 000000000..590b7556a --- /dev/null +++ b/taiga/base/templates/emails/base-body-html.jinja @@ -0,0 +1,383 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + + + + + + + {{ _("Taiga") }} + + + +
+ + + + +
+ + + + + + + + + + + + + +
+ + + + + +
+ + + +
+
+ + + + +
+ {% block body %}{% endblock %} +
+
+ {% include "emails/includes/footer.jinja" %} +
+ +
+
+ + diff --git a/taiga/base/templates/emails/hero-body-html.jinja b/taiga/base/templates/emails/hero-body-html.jinja new file mode 100644 index 000000000..c996e8572 --- /dev/null +++ b/taiga/base/templates/emails/hero-body-html.jinja @@ -0,0 +1,338 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + + + + + + + {{ _("You have been Taigatized") }} + + + +
+ + + + +
+ + + + + + + + + + + +
+ + + + + +
+ + Taiga + + {% trans product_name=sr("product_name") %} +

Welcome to {{ product_name }}, an Open Source, Agile Project Management Tool

+ {% endtrans %} +
+ +
+ + + + +
+ {% block body %}{% endblock %} +
+
+ {% include "emails/includes/footer.jinja" %} +
+ +
+
+ + diff --git a/taiga/base/templates/emails/includes/footer.css b/taiga/base/templates/emails/includes/footer.css new file mode 100644 index 000000000..18fe1852a --- /dev/null +++ b/taiga/base/templates/emails/includes/footer.css @@ -0,0 +1,57 @@ + +/* +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +*/ + +#templateFooter{ + background-color:#4C566A; +} + +.footerContent{ + font-size:14px !important; + line-height:115% !important; +} + +.footerContent td{ + color:#f5f5f5; + font-family: 'Open Sans', Arial, Helvetica; + font-size:10px; + line-height:150%; + padding-top:15px; + padding-right:17px; + padding-bottom:15px; + padding-left:20px; + vertical-align: middle; +} + +.footerContent a, .footerContent a:link, .footerContent a:visited, /* Yahoo! Mail Override */ .footerContent a .yshortcuts, .footerContent a span /* Yahoo! Mail Override */{ + /*@editable*/ color:#FFFFFF; + /*@editable*/ font-weight:bold; + /*@editable*/ text-decoration:underline; +} + +td.social-links { + text-align: right; +} + +td.social-links a { + display: inline-block; + margin-left: .5em; +} +td.social-links a img { + height: 20px; + width: auto; +} + +@media only screen and (max-width: 480px) { + .footerContent{ + font-size:14px !important; + line-height:115% !important; + } + + .footerContent a{display:block !important;} +} diff --git a/taiga/base/templates/emails/includes/footer.jinja b/taiga/base/templates/emails/includes/footer.jinja new file mode 100644 index 000000000..2c2620a3c --- /dev/null +++ b/taiga/base/templates/emails/includes/footer.jinja @@ -0,0 +1,37 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + + + + + + {% block social %} + + {% endblock %} + +
+ {% block footer %} + {% trans unsubscribe_url=resolve_front_url("settings-mail-notifications"), support_url=sr("support.url"), support_email=sr("support.email") %} + Configure email notifications or unsubscribe +  •  + Taiga Support +  •  + Contact us + {% endtrans %} + {% endblock %} +
+ \ No newline at end of file diff --git a/taiga/base/templates/emails/updates-body-html.jinja b/taiga/base/templates/emails/updates-body-html.jinja new file mode 100644 index 000000000..806fc5fea --- /dev/null +++ b/taiga/base/templates/emails/updates-body-html.jinja @@ -0,0 +1,406 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + + + + + + + {{ _("[Taiga] Updates") }} + + + +
+ + + + +
+ + + + + + + + + + + + + + + +
+ +
+ + + + + +
+ + + +
+
+ + + + + +
+ {% block head %} + {% endblock %} + + {% block body %} + + + + {% for entry in history_entries %} + {% if entry.comment %} + + + + {% endif %} + {% set changed_fields = entry.values_diff %} + {% if changed_fields %} + {% include "emails/includes/fields_diff-html.jinja" %} + {% endif %} + {% endfor %} + {% endblock %} +

{{ _("Updates") }}

+ {% trans comment=mdrender(project, entry.comment) %} +

comment:

+

{{ comment }}

+ {% endtrans %} +
+
+ +
+ {% include "emails/includes/footer.jinja" %} +
+ +
+
+ + diff --git a/taiga/base/templates/emails/updates-body-text.jinja b/taiga/base/templates/emails/updates-body-text.jinja new file mode 100644 index 000000000..5abdb9156 --- /dev/null +++ b/taiga/base/templates/emails/updates-body-text.jinja @@ -0,0 +1,23 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% block head %}{% endblock %} + +{% block body %} +{% for entry in history_entries %} + {% if entry.comment %} + {% trans comment=entry.comment %} + Comment: {{ comment }} + {% endtrans %} + {% endif %} + {% set changed_fields = entry.values_diff %} + {% if changed_fields %} + {% include "emails/includes/fields_diff-text.jinja" %} + {% endif %} +{% endfor %} +{% endblock %} diff --git a/taiga/base/throttling.py b/taiga/base/throttling.py new file mode 100644 index 000000000..53f15a4fc --- /dev/null +++ b/taiga/base/throttling.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from taiga.base.api import throttling +from ipware.ip import get_ip +from netaddr import all_matching_cidrs +from netaddr.core import AddrFormatError + + +class GlobalThrottlingMixin: + """ + Define the cache key based on the user IP independently if the user is + logged in or not. + """ + def get_cache_key(self, request, view): + ident = get_ip(request) + + return self.cache_format % { + "scope": self.scope, + "ident": ident + } + + +# If you derive a class from this mixin you have to put this class previously +# to the base throttling class. +class ThrottleByActionMixin: + throttled_actions = [] + + def has_to_finalize(self, request, response, view): + if super().has_to_finalize(request, response, view): + return view.action in self.throttled_actions + return False + + def allow_request(self, request, view): + if view.action in self.throttled_actions: + return super().allow_request(request, view) + return True + + +class AnonRateThrottle(throttling.AnonRateThrottle): + scope = "anon" + + +class UserRateThrottle(throttling.UserRateThrottle): + scope = "user" + + +class CommonThrottle(throttling.SimpleRateThrottle): + cache_format = "throttle_%(scope)s_%(rate)s_%(ident)s" + + def __init__(self): + pass + + def has_to_finalize(self, request, response, view): + return False + + def is_whitelisted(self, ident): + for whitelisted in settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST']: + if isinstance(whitelisted, int) and whitelisted == ident: + return True + elif isinstance(whitelisted, str): + try: + if all_matching_cidrs(ident, [whitelisted]) != []: + return True + except(AddrFormatError, ValueError): + pass + return False + + def allow_request(self, request, view): + scope = self.get_scope(request) + ident = self.get_ident(request) + rates = self.get_rates(scope) + + if self.is_whitelisted(ident): + return True + + if rates is None or rates == []: + return True + + now = self.timer() + + waits = [] + history_writes = [] + + for rate in rates: + rate_name = rate[0] + rate_num_requests = rate[1] + rate_duration = rate[2] + + key = self.get_cache_key(ident, scope, rate_name) + history = self.cache.get(key, []) + + while history and history[-1] <= now - rate_duration: + history.pop() + + if len(history) >= rate_num_requests: + waits.append(self.wait_time(history, rate, now)) + + history_writes.append({ + "key": key, + "history": history, + "rate_duration": rate_duration, + }) + + if waits: + self._wait = max(waits) + return False + + for history_write in history_writes: + history_write['history'].insert(0, now) + self.cache.set( + history_write['key'], + history_write['history'], + history_write['rate_duration'] + ) + return True + + def get_rates(self, scope): + try: + rates = self.THROTTLE_RATES[scope] + except KeyError: + msg = "No default throttle rate set for \"%s\" scope" % scope + raise ImproperlyConfigured(msg) + + if rates is None: + return [] + elif isinstance(rates, str): + return [self.parse_rate(rates)] + elif isinstance(rates, list): + return list(map(self.parse_rate, rates)) + else: + msg = "No valid throttle rate set for \"%s\" scope" % scope + raise ImproperlyConfigured(msg) + + def parse_rate(self, rate): + """ + Given the request rate string, return a two tuple of: + , + """ + if rate is None: + return None + num, period = rate.split("/") + num_requests = int(num) + duration = {"s": 1, "m": 60, "h": 3600, "d": 86400}[period[0]] + return (rate, num_requests, duration) + + def get_scope(self, request): + scope_prefix = "user" if request.user.is_authenticated else "anon" + scope_sufix = "write" if request.method in ["POST", "PUT", "PATCH", "DELETE"] else "read" + scope = "{}-{}".format(scope_prefix, scope_sufix) + return scope + + def get_ident(self, request): + if request.user.is_authenticated: + return request.user.id + ident = get_ip(request) + return ident + + def get_cache_key(self, ident, scope, rate): + return self.cache_format % { "scope": scope, "ident": ident, "rate": rate } + + def wait_time(self, history, rate, now): + rate_num_requests = rate[1] + rate_duration = rate[2] + + if history: + remaining_duration = rate_duration - (now - history[-1]) + else: + remaining_duration = rate_duration + + available_requests = rate_num_requests - len(history) + 1 + if available_requests <= 0: + return remaining_duration + + return remaining_duration / float(available_requests) + + def wait(self): + return self._wait + + +class SimpleRateThrottle(throttling.SimpleRateThrottle): + pass diff --git a/taiga/base/utils/__init__.py b/taiga/base/utils/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/base/utils/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/base/utils/collections.py b/taiga/base/utils/collections.py new file mode 100644 index 000000000..41dcf3828 --- /dev/null +++ b/taiga/base/utils/collections.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from collections.abc import MutableSet + + +class OrderedSet(MutableSet): + # Extract from: + # - https://docs.python.org/3/library/collections.abc.html?highlight=orderedset + # - https://code.activestate.com/recipes/576694/ + def __init__(self, iterable=None): + self.end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.map = {} # key --> [key, prev, next] + if iterable is not None: + self |= iterable + + def __len__(self): + return len(self.map) + + def __contains__(self, key): + return key in self.map + + def add(self, key): + if key not in self.map: + end = self.end + curr = end[1] + curr[2] = end[1] = self.map[key] = [key, curr, end] + + def discard(self, key): + if key in self.map: + key, prev, next = self.map.pop(key) + prev[2] = next + next[1] = prev + + def __iter__(self): + end = self.end + curr = end[2] + while curr is not end: + yield curr[0] + curr = curr[2] + + def __reversed__(self): + end = self.end + curr = end[1] + while curr is not end: + yield curr[0] + curr = curr[1] + + def pop(self, last=True): + if not self: + raise KeyError('set is empty') + key = self.end[1][0] if last else self.end[2][0] + self.discard(key) + return key + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, list(self)) + + def __eq__(self, other): + if isinstance(other, OrderedSet): + return len(self) == len(other) and list(self) == list(other) + return set(self) == set(other) diff --git a/taiga/base/utils/colors.py b/taiga/base/utils/colors.py new file mode 100644 index 000000000..f62acc7e1 --- /dev/null +++ b/taiga/base/utils/colors.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import random + +from django.conf import settings + + +DEFAULT_PREDEFINED_COLORS = ( + "#fce94f", + "#edd400", + "#c4a000", + "#8ae234", + "#73d216", + "#4e9a06", + "#d3d7cf", + "#fcaf3e", + "#f57900", + "#ce5c00", + "#729fcf", + "#3465a4", + "#204a87", + "#888a85", + "#ad7fa8", + "#75507b", + "#5c3566", + "#ef2929", + "#cc0000", + "#a40000" +) + +PREDEFINED_COLORS = getattr(settings, "PREDEFINED_COLORS", DEFAULT_PREDEFINED_COLORS) + + +def generate_random_hex_color(): + return "#{:06x}".format(random.randint(0,0xFFFFFF)) + + +def generate_random_predefined_hex_color(): + return random.choice(PREDEFINED_COLORS) + diff --git a/taiga/base/utils/contenttypes.py b/taiga/base/utils/contenttypes.py new file mode 100644 index 000000000..4a8c84148 --- /dev/null +++ b/taiga/base/utils/contenttypes.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.contrib.contenttypes.management import create_contenttypes + + +def update_all_contenttypes(**kwargs): + for app_config in apps.get_app_configs(): + create_contenttypes(app_config, **kwargs) diff --git a/taiga/base/utils/db.py b/taiga/base/utils/db.py new file mode 100644 index 000000000..1665ac93f --- /dev/null +++ b/taiga/base/utils/db.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib.contenttypes.models import ContentType +from django.db import connection +from django.db import DatabaseError +from django.db import transaction +from django.shortcuts import _get_queryset + +from . import functions + +import re + + +def get_object_or_none(klass, *args, **kwargs): + """ + Uses get() to return an object, or None if the object does not exist. + + klass may be a Model, Manager, or QuerySet object. All other passed + arguments and keyword arguments are used in the get() query. + + Note: Like with get(), an MultipleObjectsReturned will be raised if more + than one object is found. + """ + queryset = _get_queryset(klass) + try: + return queryset.get(*args, **kwargs) + except queryset.model.DoesNotExist: + return None + + +def get_typename_for_model_class(model: object, for_concrete_model=True) -> str: + """ + Get typename for model instance. + """ + if for_concrete_model: + model = model._meta.concrete_model + else: + model = model._meta.proxy_for_model + + return "{0}.{1}".format(model._meta.app_label, model._meta.model_name) + + +def get_typename_for_model_instance(model_instance): + """ + Get content type tuple from model instance. + """ + ct = ContentType.objects.get_for_model(model_instance) + return ".".join([ct.app_label, ct.model]) + + +def reload_attribute(model_instance, attr_name): + """Fetch the stored value of a model instance attribute. + + :param model_instance: Model instance. + :param attr_name: Attribute name to fetch. + """ + qs = type(model_instance).objects.filter(id=model_instance.id) + return qs.values_list(attr_name, flat=True)[0] + + +@transaction.atomic +def save_in_bulk(instances, callback=None, precall=None, **save_options): + """Save a list of model instances. + + :params instances: List of model instances. + :params callback: Callback to call after each save. + :params save_options: Additional options to use when saving each instance. + """ + ret = [] + if callback is None: + callback = functions.noop + + if precall is None: + precall = functions.noop + + for instance in instances: + created = False + if instance.pk is None: + created = True + + precall(instance) + instance.save(**save_options) + callback(instance, created=created) + + return ret + + +@transaction.atomic +def update_in_bulk(instances, list_of_new_values, callback=None, precall=None): + """Update a list of model instances. + + :params instances: List of model instances. + :params new_values: List of dicts where each dict is the new data corresponding to the instance + in the same index position as the dict. + """ + if callback is None: + callback = functions.noop + + if precall is None: + precall = functions.noop + + for instance, new_values in zip(instances, list_of_new_values): + for attribute, value in new_values.items(): + setattr(instance, attribute, value) + precall(instance) + instance.save() + callback(instance) + + +@transaction.atomic +def update_attr_in_bulk_for_ids(values, attr, model): + """Update a table using a list of ids. + + :params values: Dict of new values where the key is the pk of the element to update. + :params attr: attr to update + :params model: Model of the ids. + """ + if not values: + return + + values = [str((id, order)) for id, order in values.items()] + sql = """ + UPDATE "{tbl}" + SET "{attr}"=update_values.column2 + FROM ( + VALUES + {values} + ) AS update_values + WHERE "{tbl}"."id"=update_values.column1; + """.format(tbl=model._meta.db_table, + values=', '.join(values), + attr=attr) + + cursor = connection.cursor() + + # We can have deadlocks with multiple updates over the same object + # In that situation we just retry + import time + ts = time.time() + def trace_info(retries): + return '/* query=update_attr_in_bulk id={ts} retries={retries} */'.format(retries=retries, ts=ts) + + def _run_sql(retries=0, max_retries=3): + try: + cursor.execute(trace_info(retries) + sql) + except DatabaseError: + if retries < max_retries: + _run_sql(retries + 1) + + transaction.on_commit(_run_sql) + + +def to_tsquery(term): + """ + Based on: https://gist.github.com/wolever/1a5ccf6396f00229b2dc + Escape a query string so it's safe to use with Postgres' + ``to_tsquery(...)``. Single quotes are ignored, double quoted strings + are used as literals, and the logical operators 'and', 'or', 'not', + '(', and ')' can be used: + >>> tsquery_escape("Hello") + "'hello':*" + >>> tsquery_escape('"Quoted string"') + "'quoted string'" + >>> tsquery_escape("multiple terms OR another") + "'multiple':* & 'terms':* | 'another':*" + >>> tsquery_escape("'\"*|"") + "'\"*|':*" + >>> tsquery_escape('not foo and (bar or "baz")') + "! 'foo':* & ( 'bar':* | 'baz' )" + """ + + magic_terms = { + "and": "&", + "or": "|", + "not": "!", + "OR": "|", + "AND": "&", + "NOT": "!", + "(": "(", + ")": ")", + } + magic_values = set(magic_terms.values()) + paren_count = 0 + res = [] + bits = re.split(r'((?:".*?")|[()])', term) + for bit in bits: + if not bit: + continue + split_bits = ( + [bit] if bit.startswith('"') and bit.endswith('"') else + bit.strip().split() + ) + for bit in split_bits: + if not bit: + continue + if bit in magic_terms: + bit = magic_terms[bit] + last = res and res[-1] or "" + + if bit == ")": + if last == "(": + paren_count -= 1 + res.pop() + continue + if paren_count == 0: + continue + if last in magic_values and last != "(": + res.pop() + elif bit == "|" and last == "&": + res.pop() + elif bit == "!": + pass + elif bit == "(": + pass + elif last in magic_values or not last: + continue + + if bit == ")": + paren_count -= 1 + elif bit == "(": + paren_count += 1 + + res.append(bit) + if bit == ")": + res.append("&") + continue + + bit = bit.replace("'", "") + bit = bit.replace("\\", "") + if not bit: + continue + + if bit.startswith('"') and bit.endswith('"') and len(bit) > 2: + res.append(bit.replace('"', "'")) + else: + res.append("'%s':*" % (bit.replace("'", ""), )) + + res.append("&") + + while res and res[-1] in magic_values: + last = res[-1] + if last == ")": + break + if last == "(": + paren_count -= 1 + res.pop() + while paren_count > 0: + res.append(")") + paren_count -= 1 + + return " ".join(res) diff --git a/taiga/base/utils/dicts.py b/taiga/base/utils/dicts.py new file mode 100644 index 000000000..6d17c21dd --- /dev/null +++ b/taiga/base/utils/dicts.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import collections + + +def dict_sum(*args): + result = collections.Counter() + for arg in args: + assert isinstance(arg, dict) + result += collections.Counter(arg) + return result + + +def into_namedtuple(dictionary): + return collections.namedtuple('GenericDict', dictionary.keys())(**dictionary) diff --git a/taiga/base/utils/diff.py b/taiga/base/utils/diff.py new file mode 100644 index 000000000..60807dde1 --- /dev/null +++ b/taiga/base/utils/diff.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +def make_diff(first: dict, second: dict, not_found_value=None, + excluded_keys: tuple = ()) -> dict: + """ + Compute a diff between two dicts. + """ + diff = {} + # Check all keys in first dict + for key in first: + if key not in second: + diff[key] = (first[key], not_found_value) + elif first[key] != second[key]: + diff[key] = (first[key], second[key]) + + # Check all keys in second dict to find missing + for key in second: + if key not in first: + diff[key] = (not_found_value, second[key]) + + # Remove A -> A changes that usually happens with None -> None + for key, value in list(diff.items()): + frst, scnd = value + if frst == scnd: + del diff[key] + + # Removed excluded keys + for key in excluded_keys: + if key in diff: + del diff[key] + + return diff diff --git a/taiga/base/utils/files.py b/taiga/base/utils/files.py new file mode 100644 index 000000000..1b116c163 --- /dev/null +++ b/taiga/base/utils/files.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import hashlib + +from os import path, urandom +from unidecode import unidecode + +from django.template.defaultfilters import slugify +from django.utils import timezone +from django.utils.encoding import force_bytes + +from taiga.base.utils.iterators import split_by_n + +def get_file_path(instance, filename, base_path): + basename = path.basename(filename).lower() + base, ext = path.splitext(basename) + base = slugify(unidecode(base))[0:100] + basename = "".join([base, ext]) + + hs = hashlib.sha256() + hs.update(force_bytes(timezone.now().isoformat())) + hs.update(urandom(1024)) + + p1, p2, p3, p4, *p5 = split_by_n(hs.hexdigest(), 1) + hash_part = path.join(p1, p2, p3, p4, "".join(p5)) + + return path.join(base_path, hash_part, basename) diff --git a/taiga/base/utils/functions.py b/taiga/base/utils/functions.py new file mode 100644 index 000000000..59c488575 --- /dev/null +++ b/taiga/base/utils/functions.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +def noop(*args, **kwargs): + """The noop function.""" + return None diff --git a/taiga/base/utils/iterators.py b/taiga/base/utils/iterators.py new file mode 100644 index 000000000..5414b6c3c --- /dev/null +++ b/taiga/base/utils/iterators.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from functools import wraps, partial +from django.core.paginator import Paginator + + +def as_tuple(function=None, *, remove_nulls=False): + if function is None: + return partial(as_tuple, remove_nulls=remove_nulls) + + @wraps(function) + def _decorator(*args, **kwargs): + return list(function(*args, **kwargs)) + + return _decorator + + +def as_dict(function): + @wraps(function) + def _decorator(*args, **kwargs): + return dict(function(*args, **kwargs)) + return _decorator + + +def split_by_n(seq:str, n:int): + """ + A generator to divide a sequence into chunks of n units. + """ + while seq: + yield seq[:n] + seq = seq[n:] + + +def iter_queryset(queryset, itersize:int=20): + """ + Util function for iterate in more efficient way + all queryset. + """ + paginator = Paginator(queryset, itersize) + for page_num in paginator.page_range: + page = paginator.page(page_num) + for element in page.object_list: + yield element diff --git a/taiga/base/utils/json.py b/taiga/base/utils/json.py new file mode 100644 index 000000000..846ee953a --- /dev/null +++ b/taiga/base/utils/json.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.encoding import force_str + +from taiga.base.api.utils import encoders + +import json + + +def dumps(data, ensure_ascii=True, encoder_class=encoders.JSONEncoder, indent=None): + return json.dumps(data, cls=encoder_class, ensure_ascii=ensure_ascii, indent=indent) + + +def loads(data): + if isinstance(data, bytes): + data = force_str(data) + return json.loads(data) + +load = json.load + +# Some backward compatibility that should +# be removed in near future. +to_json = dumps +from_json = loads diff --git a/taiga/base/utils/logs.py b/taiga/base/utils/logs.py new file mode 100644 index 000000000..35b79b183 --- /dev/null +++ b/taiga/base/utils/logs.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.views.debug import ExceptionReporter +from django.utils.log import AdminEmailHandler +from django.conf import settings +from django import template +from copy import copy + + +class CustomAdminEmailHandler(AdminEmailHandler): + def emit(self,record): + try: + request = record.request + subject = '%s (%s IP): %s' % ( + record.levelname, + ('internal' if request.META.get('REMOTE_ADDR') in settings.INTERNAL_IPS + else 'EXTERNAL'), + record.getMessage() + ) + except Exception: + subject = '%s: %s' % ( + record.levelname, + record.getMessage() + ) + request = None + subject = self.format_subject(subject) + + # Since we add a nicely formatted traceback on our own, create a copy + # of the log record without the exception data. + no_exc_record = copy(record) + no_exc_record.exc_info = None + no_exc_record.exc_text = None + + if record.exc_info: + exc_info = record.exc_info + else: + exc_info = (None, record.getMessage(), None) + + reporter = ExceptionReporter(request, is_email=True, *exc_info) + + error_message ="\n".join(reporter.get_traceback_text().strip().split("GET:")[0].splitlines()[-4:-1]) + + message = "%s\n\n%s" % (self.format(no_exc_record), error_message) + html_message = reporter.get_traceback_html() if self.include_html else None + + self.send_mail(subject, message, fail_silently=True, html_message=html_message) diff --git a/taiga/base/utils/signals.py b/taiga/base/utils/signals.py new file mode 100644 index 000000000..bc7d4eb6a --- /dev/null +++ b/taiga/base/utils/signals.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from contextlib import contextmanager + + +@contextmanager +def without_signals(*disablers): + for disabler in disablers: + if not (isinstance(disabler, list) or isinstance(disabler, tuple)) or len(disabler) == 0: + raise ValueError("The parameters must be lists of at least one parameter (the signal).") + + signal, *ids = disabler + signal.backup_receivers = signal.receivers + signal.receivers = list(filter(lambda x: x[0][0] not in ids, signal.receivers)) + + try: + yield + except Exception as e: + raise e + finally: + for disabler in disablers: + signal, *ids = disabler + signal.receivers = signal.backup_receivers diff --git a/taiga/base/utils/slug.py b/taiga/base/utils/slug.py new file mode 100644 index 000000000..d857be149 --- /dev/null +++ b/taiga/base/utils/slug.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.template.defaultfilters import slugify as django_slugify + +import time + +from unidecode import unidecode + + +def slugify(value): + """ + Return a slug + """ + return django_slugify(unidecode(value or "")) + + +def slugify_uniquely(value, model, slugfield="slug"): + """ + Returns a slug on a name which is unique within a model's table + """ + + suffix = 0 + potential = base = django_slugify(unidecode(value)) + if len(potential) == 0: + potential = 'null' + while True: + if suffix: + potential = "-".join([base, str(suffix)]) + if not model.objects.filter(**{slugfield: potential}).exists(): + return potential + suffix += 1 + + +def slugify_uniquely_for_queryset(value, queryset, slugfield="slug"): + """ + Returns a slug on a name which doesn't exist in a queryset + """ + + suffix = 0 + potential = base = django_slugify(unidecode(value)) + if len(potential) == 0: + potential = 'null' + while True: + if suffix: + potential = "-".join([base, str(suffix)]) + if not queryset.filter(**{slugfield: potential}).exists(): + return potential + suffix += 1 + + +def ref_uniquely(p, seq_field, model, field='ref'): + project = p.__class__.objects.select_for_update().get(pk=p.pk) + ref = getattr(project, seq_field) + 1 + + while True: + params = {field: ref, 'project': project} + if not model.objects.filter(**params).exists(): + setattr(project, seq_field, ref) + project.save(update_fields=[seq_field]) + return ref + + time.sleep(0.0002) + ref += 1 diff --git a/taiga/base/utils/text.py b/taiga/base/utils/text.py new file mode 100644 index 000000000..9521de2a1 --- /dev/null +++ b/taiga/base/utils/text.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +def strip_lines(text): + """ + Given text, try remove unnecessary spaces and + put text in one unique line. + """ + return text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ").strip() + + +def split_in_lines(text): + """Split a block of text in lines removing unnecessary spaces from each line.""" + return (line for line in map(str.strip, text.split("\n")) if line) diff --git a/taiga/base/utils/thumbnails.py b/taiga/base/utils/thumbnails.py new file mode 100644 index 000000000..9523ae142 --- /dev/null +++ b/taiga/base/utils/thumbnails.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import os + +from psd_tools import PSDImage +from django.db.models.fields.files import FieldFile + +from taiga.base.utils.urls import get_absolute_url + +from easy_thumbnails.files import get_thumbnailer +from easy_thumbnails.exceptions import InvalidImageFormatError +from PIL import Image +from PIL.PngImagePlugin import PngImageFile + +from io import BytesIO + +# SVG thumbnail generator +try: + from cairosvg.surface import PNGSurface + from cairosvg.url import fetch + import magic + + def url_fetcher(url, resource_type): + if url.startswith("data:"): + return fetch(url, resource_type) + return b"" + + + def svg_image_factory(fp, filename): + mime_type = magic.from_buffer(fp.read(1024), mime=True) + if mime_type != "image/svg+xml": + raise TypeError + + fp.seek(0) + png_data = PNGSurface.convert(fp.read(), url_fetcher=url_fetcher) + return PngImageFile(BytesIO(png_data)) + + Image.register_mime("SVG", "image/svg+xml") + Image.register_extension("SVG", ".svg") + Image.register_open("SVG", svg_image_factory) +except Exception: + pass + +Image.init() + + +# PSD thumbnail generator +def psd_image_factory(data, *args): + try: + return PSDImage.open(data).compose() + except Exception: + raise TypeError + + +Image.register_open("PSD", psd_image_factory) + + +def get_thumbnail(file_obj, thumbnailer_size): + # Ugly hack to temporary ignore tiff files + relative_name = file_obj + if isinstance(file_obj, FieldFile): + relative_name = file_obj.name + + source_extension = os.path.splitext(relative_name)[1][1:] + if source_extension.lower() not in ('png', 'svg', 'gif', 'bmp', 'jpeg', 'jpg', 'psd'): + return None + + try: + thumbnailer = get_thumbnailer(file_obj) + return thumbnailer[thumbnailer_size] + except InvalidImageFormatError: + return None + + +def get_thumbnail_url(file_obj, thumbnailer_size): + thumbnail = get_thumbnail(file_obj, thumbnailer_size) + + if not thumbnail: + return None + + path_url = thumbnail.url + thumb_url = get_absolute_url(path_url) + return thumb_url diff --git a/taiga/base/utils/time.py b/taiga/base/utils/time.py new file mode 100644 index 000000000..1cde4ad8c --- /dev/null +++ b/taiga/base/utils/time.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import time + + +def timestamp_ms(): + """Ruturn timestamp in milisecond.""" + return int(time.time() * 1000) + + +def timestamp_mics(): + """Return timestamp in microseconds.""" + return int(time.time() * 1000000) diff --git a/taiga/base/utils/urls.py b/taiga/base/utils/urls.py new file mode 100644 index 000000000..9e77850c0 --- /dev/null +++ b/taiga/base/utils/urls.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import ipaddress +import socket +from urllib.parse import urlparse + +import django_sites as sites +from django.urls import reverse as django_reverse +from django.utils.translation import gettext as _ + +URL_TEMPLATE = "{scheme}://{domain}/{path}" + + +def build_url(path, scheme="http", domain="localhost"): + return URL_TEMPLATE.format(scheme=scheme, domain=domain, path=path.lstrip("/")) + + +def is_absolute_url(path): + """Test wether or not `path` is absolute url.""" + return path.startswith("http") + + +def get_absolute_url(path): + """Return a path as an absolute url.""" + if is_absolute_url(path): + return path + site = sites.get_current() + return build_url(path, scheme=site.scheme, domain=site.domain) + + +def reverse(viewname, *args, **kwargs): + """Same behavior as django's reverse but uses django_sites to compute absolute url.""" + return get_absolute_url(django_reverse(viewname, *args, **kwargs)) + + +class HostnameException(Exception): + pass + + +class IpAddresValueError(ValueError): + pass + + +def validate_private_url(url): + host = urlparse(url).hostname + port = urlparse(url).port + + try: + socket_args, *others = socket.getaddrinfo(host, port) + except Exception: + raise HostnameException(_("Host access error")) + + destination_address = socket_args[4][0] + try: + ipa = ipaddress.ip_address(destination_address) + except ValueError: + raise IpAddresValueError(_("IP Address error")) + if ipa.is_private: + raise IpAddresValueError("Private IP Address not allowed") diff --git a/taiga/celery.py b/taiga/celery.py new file mode 100644 index 000000000..89628bfb4 --- /dev/null +++ b/taiga/celery.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import absolute_import, unicode_literals +import random +import os + +from celery import Celery +from celery.schedules import crontab + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.common") + +from django.conf import settings + +app = Celery('taiga') +app.config_from_object('django.conf:settings', namespace='CELERY') + +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) + +if settings.ENABLE_TELEMETRY: + rng = random.Random(settings.SECRET_KEY) + hour = rng.randint(0, 4) + minute = rng.randint(0, 59) + app.conf.beat_schedule['send-telemetry-once-a-day'] = { + 'task': 'taiga.telemetry.tasks.send_telemetry', + 'schedule': crontab(minute=minute, hour=hour), + 'args': (), + } + +if settings.SEND_BULK_EMAILS_WITH_CELERY and settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL > 0: + app.conf.beat_schedule['send-bulk-emails'] = { + 'task': 'taiga.projects.notifications.tasks.send_bulk_email', + 'schedule': settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL, + 'args': (), + } + +if ('taiga.auth.token_denylist' in settings.INSTALLED_APPS and + getattr(settings, "FLUSH_REFRESHED_TOKENS_PERIODICITY", None)): + app.conf.beat_schedule['auth-flush-expired-tokens'] = { + 'task': 'taiga.auth.token_denylist.tasks.flush_expired_tokens', + 'schedule': settings.FLUSH_REFRESHED_TOKENS_PERIODICITY, + 'args': (), + } diff --git a/taiga/events/__init__.py b/taiga/events/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/events/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/events/apps.py b/taiga/events/apps.py new file mode 100644 index 000000000..b2b142e12 --- /dev/null +++ b/taiga/events/apps.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.apps import apps, AppConfig +from django.db.models import signals + + +def connect_events_signals(): + from . import signal_handlers as handlers + signals.post_save.connect(handlers.on_save_any_model, dispatch_uid="events_change") + signals.post_delete.connect(handlers.on_delete_any_model, dispatch_uid="events_delete") + + +def disconnect_events_signals(): + signals.post_save.disconnect(dispatch_uid="events_change") + signals.post_delete.disconnect(dispatch_uid="events_delete") + + +class EventsAppConfig(AppConfig): + name = "taiga.events" + verbose_name = "Events App Config" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.events_watched_types = set() + + def ready(self): + connect_events_signals() + for config in apps.get_app_configs(): + if not hasattr(config, 'watched_types'): + continue + + self.events_watched_types = self.events_watched_types.union(config.watched_types) diff --git a/taiga/events/backends/__init__.py b/taiga/events/backends/__init__.py new file mode 100644 index 000000000..dc8680ba2 --- /dev/null +++ b/taiga/events/backends/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from .base import get_events_backend + +__all__ = ["get_events_backend"] diff --git a/taiga/events/backends/base.py b/taiga/events/backends/base.py new file mode 100644 index 000000000..f9e59220c --- /dev/null +++ b/taiga/events/backends/base.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import abc +import importlib + +from django.core.exceptions import ImproperlyConfigured +from django.conf import settings + + +class BaseEventsPushBackend(object, metaclass=abc.ABCMeta): + @abc.abstractmethod + def emit_event(self, message:str, *, routing_key:str, channel:str="events"): + pass + + +def load_class(path): + """ + Load class from path. + """ + + mod_name, klass_name = path.rsplit('.', 1) + + try: + mod = importlib.import_module(mod_name) + except AttributeError as e: + raise ImproperlyConfigured('Error importing {0}: "{1}"'.format(mod_name, e)) + + try: + klass = getattr(mod, klass_name) + except AttributeError: + raise ImproperlyConfigured('Module "{0}" does not define a "{1}" class'.format(mod_name, klass_name)) + + return klass + + +def get_events_backend(path:str=None, options:dict=None): + if path is None: + path = getattr(settings, "EVENTS_PUSH_BACKEND", None) + + if path is None: + raise ImproperlyConfigured("Events push system not configured") + + if options is None: + options = getattr(settings, "EVENTS_PUSH_BACKEND_OPTIONS", {}) + + cls = load_class(path) + return cls(**options) diff --git a/taiga/events/backends/postgresql.py b/taiga/events/backends/postgresql.py new file mode 100644 index 000000000..e0fcc34d8 --- /dev/null +++ b/taiga/events/backends/postgresql.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import transaction +from django.db import connection + +from . import base + + +class EventsPushBackend(base.BaseEventsPushBackend): + @transaction.atomic + def emit_event(self, message:str, *, routing_key:str, channel:str="events"): + routing_key = routing_key.replace(".", "__") + channel = "{channel}_{routing_key}".format(channel=channel, + routing_key=routing_key) + sql = "NOTIFY {channel}, %s".format(channel=channel) + cursor = connection.cursor() + cursor.execute(sql, [message]) + cursor.close() diff --git a/taiga/events/backends/rabbitmq.py b/taiga/events/backends/rabbitmq.py new file mode 100644 index 000000000..b2062a3e8 --- /dev/null +++ b/taiga/events/backends/rabbitmq.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import json +import logging + +from amqp import Connection as AmqpConnection +from amqp.exceptions import AccessRefused +from amqp.basic_message import Message as AmqpMessage +from urllib.parse import urlparse + +from . import base + +log = logging.getLogger("tagia.events") + + +def _make_rabbitmq_connection(url): + parse_result = urlparse(url) + + # Parse host & user/password + try: + (authdata, host) = parse_result.netloc.split("@") + except Exception as e: + raise RuntimeError("Invalid url") from e + + try: + (user, password) = authdata.split(":") + except Exception: + (user, password) = ("guest", "guest") + + vhost = parse_result.path + return AmqpConnection(host=host, userid=user, + password=password, virtual_host=vhost[1:]) + + +class EventsPushBackend(base.BaseEventsPushBackend): + def __init__(self, url): + self.url = url + + def emit_event(self, message:str, *, routing_key:str, channel:str="events"): + connection = _make_rabbitmq_connection(self.url) + try: + connection.connect() + except ConnectionRefusedError: + err_msg = "EventsPushBackend: Unable to connect with RabbitMQ (connection refused) at {}".format( + self.url) + log.error(err_msg, exc_info=True) + except AccessRefused: + err_msg = "EventsPushBackend: Unable to connect with RabbitMQ (access refused) at {}".format( + self.url) + log.error(err_msg, exc_info=True) + else: + try: + message = AmqpMessage(message) + rchannel = connection.channel() + + rchannel.exchange_declare(exchange=channel, type="topic", auto_delete=True) + rchannel.basic_publish(message, routing_key=routing_key, exchange=channel) + rchannel.close() + except Exception: + log.error("EventsPushBackend: Unhandled exception", exc_info=True) + finally: + connection.close() diff --git a/taiga/events/events.py b/taiga/events/events.py new file mode 100644 index 000000000..735ac003d --- /dev/null +++ b/taiga/events/events.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import collections + +from django.db import connection +from django.utils.translation import gettext_lazy as _ + +from django.conf import settings + +from taiga.base.utils import json +from taiga.base.utils.db import get_typename_for_model_instance +from . import middleware as mw +from . import backends +from taiga.front.templatetags.functions import resolve +from taiga.projects.history.choices import HistoryType + + +def emit_event(data:dict, routing_key:str, *, + sessionid:str=None, channel:str="events", + on_commit:bool=True): + if not sessionid: + sessionid = mw.get_current_session_id() + + data = {"session_id": sessionid, + "data": data} + + backend = backends.get_events_backend() + + def backend_emit_event(): + backend.emit_event(message=json.dumps(data), routing_key=routing_key, channel=channel) + + if on_commit: + connection.on_commit(backend_emit_event) + else: + backend_emit_event() + + +def emit_event_for_model(obj, *, type:str="change", channel:str="events", + content_type:str=None, sessionid:str=None): + """ + Sends a model change event. + + type: create | change | delete + """ + + if hasattr(obj, "_importing") and obj._importing: + return None + + if hasattr(obj, "_excluded_events") and type in obj.excluded_events: + return None + + assert type in set(["create", "change", "delete"]) + assert hasattr(obj, "project_id") + + if not content_type: + content_type = get_typename_for_model_instance(obj) + + projectid = getattr(obj, "project_id") + pk = getattr(obj, "pk", None) + + app_name, model_name = content_type.split(".", 1) + routing_key = "changes.project.{0}.{1}".format(projectid, app_name) + + if app_name in settings.INSTALLED_APPS: + routing_key = "%s.%s" % (routing_key, model_name) + + data = {"type": type, + "matches": content_type, + "pk": pk} + + return emit_event(routing_key=routing_key, + channel=channel, + 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"): + """ + Sends a model live notification to users. + """ + + if obj._importing: + return None + + content_type = get_typename_for_model_instance(obj) + if content_type == "userstories.userstory": + if history.type == HistoryType.create: + title = _("User story created") + url = resolve("userstory", obj.project.slug, obj.ref) + elif history.type == HistoryType.change: + title = _("User story changed") + url = resolve("userstory", obj.project.slug, obj.ref) + else: + title = _("User story deleted") + url = None + body = _("US #{} - {}").format(obj.ref, obj.subject) + elif content_type == "tasks.task": + if history.type == HistoryType.create: + title = _("Task created") + url = resolve("task", obj.project.slug, obj.ref) + elif history.type == HistoryType.change: + title = _("Task changed") + url = resolve("task", obj.project.slug, obj.ref) + else: + title = _("Task deleted") + url = None + body = _("Task #{} - {}").format(obj.ref, obj.subject) + elif content_type == "issues.issue": + if history.type == HistoryType.create: + title = _("Issue created") + url = resolve("issue", obj.project.slug, obj.ref) + elif history.type == HistoryType.change: + title = _("Issue changed") + url = resolve("issue", obj.project.slug, obj.ref) + else: + title = _("Issue deleted") + url = None + body = _("Issue: #{} - {}").format(obj.ref, obj.subject) + elif content_type == "wiki.wiki_page": + if history.type == HistoryType.create: + title = _("Wiki Page created") + url = resolve("wiki", obj.project.slug, obj.slug) + elif history.type == HistoryType.change: + title = _("Wiki Page changed") + url = resolve("wiki", obj.project.slug, obj.slug) + else: + title = _("Wiki Page deleted") + url = None + body = _("Wiki Page: {}").format(obj.slug) + elif content_type == "milestones.milestone": + if history.type == HistoryType.create: + title = _("Sprint created") + url = resolve("taskboard", obj.project.slug, obj.slug) + elif history.type == HistoryType.change: + title = _("Sprint changed") + url = resolve("taskboard", obj.project.slug, obj.slug) + else: + title = _("Sprint deleted") + url = None + body = _("Sprint: {}").format(obj.name) + else: + return None + + return emit_event( + { + "title": title, + "body": "Project: {}\n{}".format(obj.project.name, body), + "url": url, + "timeout": 10000, + "id": history.id + }, + "live_notifications.{}".format(user.id), + sessionid=sessionid + ) + +def emit_event_for_ids(ids, content_type:str, projectid:int, *, + type:str="change", channel:str="events", sessionid:str=None): + assert type in set(["create", "change", "delete"]) + assert isinstance(ids, collections.abc.Iterable) + assert content_type, "'content_type' parameter is mandatory" + + app_name, model_name = content_type.split(".", 1) + routing_key = "changes.project.{0}.{1}".format(projectid, app_name) + + data = {"type": type, + "matches": content_type, + "pk": ids} + + return emit_event(routing_key=routing_key, + channel=channel, + sessionid=sessionid, + data=data) diff --git a/taiga/events/management/__init__.py b/taiga/events/management/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/events/management/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/events/management/commands/emit_notification_message.py b/taiga/events/management/commands/emit_notification_message.py new file mode 100644 index 000000000..a940a3309 --- /dev/null +++ b/taiga/events/management/commands/emit_notification_message.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand + +from taiga.events.events import emit_event + +class Command(BaseCommand): + help = 'Send a notification message to the current users' + + def add_arguments(self, parser): + parser.add_argument("title", help="The title of the message.") + parser.add_argument("description", help="The description of the message.") + + def handle(self, **options): + data = { + "title": options["title"], + "desc": options["description"], + } + routing_key = "notifications" + emit_event(data, routing_key, on_commit=False) diff --git a/taiga/events/middleware.py b/taiga/events/middleware.py new file mode 100644 index 000000000..d87adc4c2 --- /dev/null +++ b/taiga/events/middleware.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import threading + +_local = threading.local() +_local.session_id = None + + +def get_current_session_id() -> str: + """ + Get current session id for current + request. + + This function should be used only whithin + request context. Out of request context + it always return None + """ + + global _local + if not hasattr(_local, "session_id"): + raise RuntimeError("No session identifier is found, " + "are you sure that session id middleware " + "is active?") + return _local.session_id + + +class SessionIDMiddleware(object): + """ + Middleware for extract and store a current web sesion + identifier to thread local storage (that only avaliable for + current thread). + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + self.process_request(request) + response = self.get_response(request) + self.process_response(request, response) + + return response + + + def process_request(self, request): + global _local + session_id = request.headers.get("x-session-id", None) + _local.session_id = session_id + request.session_id = session_id + + def process_response(self, request, response): + global _local + _local.session_id = None + + return response diff --git a/taiga/events/signal_handlers.py b/taiga/events/signal_handlers.py new file mode 100644 index 000000000..1e0292b9f --- /dev/null +++ b/taiga/events/signal_handlers.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.apps import apps +from taiga.base.utils.db import get_typename_for_model_instance + +from . import middleware as mw +from . import events + + +def on_save_any_model(sender, instance, created, **kwargs): + # Ignore any object that can not have project_id + if not hasattr(instance, "project_id"): + return + content_type = get_typename_for_model_instance(instance) + + # Ignore any other events + app_config = apps.get_app_config('events') + if content_type not in app_config.events_watched_types: + return + + sesionid = mw.get_current_session_id() + type = "create" if created else "change" + + events.emit_event_for_model(instance, sessionid=sesionid, type=type) + + +def on_delete_any_model(sender, instance, **kwargs): + # Ignore any object that can not have project_id + content_type = get_typename_for_model_instance(instance) + + # Ignore any other changes + app_config = apps.get_app_config('events') + if content_type not in app_config.events_watched_types: + return + + sesionid = mw.get_current_session_id() + + events.emit_event_for_model(instance, sessionid=sesionid, type="delete") diff --git a/taiga/export_import/__init__.py b/taiga/export_import/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/export_import/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py new file mode 100644 index 000000000..992e4579f --- /dev/null +++ b/taiga/export_import/api.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import codecs +import uuid +import gzip +import logging + + +from django.utils.decorators import method_decorator +from django.utils.translation import gettext as _ +from django.db.transaction import atomic +from django.db.models import signals +from django.conf import settings +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile + +from taiga.base.utils import json +from taiga.base.decorators import detail_route, list_route +from taiga.base import exceptions as exc +from taiga.base import response +from taiga.base.api.mixins import CreateModelMixin +from taiga.base.api.viewsets import GenericViewSet +from taiga.projects import utils as project_utils +from taiga.projects.models import Project, Membership +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.serializers import ProjectSerializer +from taiga.users import services as users_services + +from . import exceptions as err +from . import mixins +from . import permissions +from . import validators +from . import serializers +from . import services +from . import tasks +from . import throttling + +from taiga.base.api.utils import get_object_or_error + +logger = logging.getLogger(__name__) + +class ProjectExporterViewSet(mixins.ImportThrottlingPolicyMixin, GenericViewSet): + model = Project + permission_classes = (permissions.ImportExportPermission, ) + + def retrieve(self, request, pk, *args, **kwargs): + throttle = throttling.ImportDumpModeRateThrottle() + + if not throttle.allow_request(request, self): + self.throttled(request, throttle.wait()) + + project = get_object_or_error(self.get_queryset(), request.user, pk=pk) + self.check_permissions(request, 'export_project', project) + + dump_format = request.QUERY_PARAMS.get("dump_format", "plain") + + if settings.CELERY_ENABLED: + task = tasks.dump_project.delay(request.user, project, dump_format) + tasks.delete_project_dump.apply_async((project.pk, project.slug, task.id, dump_format), + countdown=settings.EXPORTS_TTL) + return response.Accepted({"export_id": task.id}) + + if dump_format == "gzip": + path = "exports/{}/{}-{}.json.gz".format(project.pk, project.slug, uuid.uuid4().hex) + with default_storage.open(path, mode="wb") as outfile: + services.render_project(project, gzip.GzipFile(fileobj=outfile, mode="wb")) + else: + path = "exports/{}/{}-{}.json".format(project.pk, project.slug, uuid.uuid4().hex) + with default_storage.open(path, mode="wb") as outfile: + services.render_project(project, outfile) + + response_data = { + "url": default_storage.url(path) + } + return response.Ok(response_data) + + +class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixin, GenericViewSet): + model = Project + permission_classes = (permissions.ImportExportPermission, ) + + @method_decorator(atomic) + def create(self, request, *args, **kwargs): + self.check_permissions(request, 'import_project', None) + + data = request.DATA.copy() + data['owner'] = data.get('owner', request.user.email) + + # Validate if the project can be imported + is_private = data.get('is_private', False) + memberships = [m["email"] for m in data.get("memberships", []) if m.get("email", None)] + (enough_slots, error_message, total_memberships) = services.has_available_slot_for_new_project( + self.request.user, + is_private, + memberships + ) + if not enough_slots: + raise exc.NotEnoughSlotsForProject(is_private, total_memberships, error_message) + + # Create Project + project_serialized = services.store.store_project(data) + + if not project_serialized: + raise exc.BadRequest(services.store.get_errors()) + + # Create roles + roles_serialized = None + if "roles" in data: + roles_serialized = services.store.store_roles(project_serialized.object, data) + + if not roles_serialized: + raise exc.BadRequest(_("We needed at least one role")) + + # Create memberships + if "memberships" in data: + services.store.store_memberships(project_serialized.object, data) + + try: + owner_membership = project_serialized.object.memberships.get(user=project_serialized.object.owner) + owner_membership.is_admin = True + owner_membership.save() + except Membership.DoesNotExist: + Membership.objects.create( + project=project_serialized.object, + email=project_serialized.object.owner.email, + user=project_serialized.object.owner, + role=project_serialized.object.roles.all().first(), + is_admin=True + ) + + # Create project values choices + if "points" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "points", validators.PointsExportValidator) + if "issue_types" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "issue_types", + validators.IssueTypeExportValidator) + if "issue_statuses" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "issue_statuses", + validators.IssueStatusExportValidator,) + if "issue_duedates" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "issue_duedates", + validators.IssueDueDateExportValidator,) + if "us_statuses" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "us_statuses", + validators.UserStoryStatusExportValidator,) + if "us_duedates" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "us_duedates", + validators.UserStoryDueDateExportValidator,) + if "task_statuses" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "task_statuses", + validators.TaskStatusExportValidator) + if "task_duedates" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "task_duedates", + validators.TaskDueDateExportValidator) + if "priorities" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "priorities", + validators.PriorityExportValidator) + if "severities" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "severities", + validators.SeverityExportValidator) + if "swimlanes" in data: + services.store.store_project_attributes_values(project_serialized.object, data, + "swimlanes", + validators.SwimlaneExportValidator) + + if ("points" in data or "issues_types" in data or + "issues_statuses" in data or "us_statuses" in data or + "task_statuses" in data or "priorities" in data or + "severities" in data): + services.store.store_default_project_attributes_values(project_serialized.object, data) + + # Created custom attributes + if "userstorycustomattributes" in data: + services.store.store_custom_attributes(project_serialized.object, data, + "userstorycustomattributes", + validators.UserStoryCustomAttributeExportValidator) + + if "taskcustomattributes" in data: + services.store.store_custom_attributes(project_serialized.object, data, + "taskcustomattributes", + validators.TaskCustomAttributeExportValidator) + + if "issuecustomattributes" in data: + services.store.store_custom_attributes(project_serialized.object, data, + "issuecustomattributes", + validators.IssueCustomAttributeExportValidator) + + # Is there any error? + errors = services.store.get_errors() + if errors: + raise exc.BadRequest(errors) + + # Importer process is OK + response_data = serializers.ProjectExportSerializer(project_serialized.object).data + response_data['id'] = project_serialized.object.id + headers = self.get_success_headers(response_data) + return response.Created(response_data, headers=headers) + + @detail_route(methods=['post']) + @method_decorator(atomic) + def milestone(self, request, *args, **kwargs): + project = self.get_object_or_none() + self.check_permissions(request, 'import_item', project) + + milestone = services.store.store_milestone(project, request.DATA.copy()) + + errors = services.store.get_errors() + if errors: + raise exc.BadRequest(errors) + + data = serializers.MilestoneExportSerializer(milestone.object).data + headers = self.get_success_headers(data) + return response.Created(data, headers=headers) + + @detail_route(methods=['post']) + @method_decorator(atomic) + def us(self, request, *args, **kwargs): + project = self.get_object_or_none() + self.check_permissions(request, 'import_item', project) + + us = services.store.store_user_story(project, request.DATA.copy()) + + errors = services.store.get_errors() + if errors: + raise exc.BadRequest(errors) + + data = serializers.UserStoryExportSerializer(us.object).data + headers = self.get_success_headers(data) + return response.Created(data, headers=headers) + + @detail_route(methods=['post']) + @method_decorator(atomic) + def task(self, request, *args, **kwargs): + project = self.get_object_or_none() + self.check_permissions(request, 'import_item', project) + + signals.pre_save.disconnect(sender=Task, + dispatch_uid="set_finished_date_when_edit_task") + + task = services.store.store_task(project, request.DATA.copy()) + + errors = services.store.get_errors() + if errors: + raise exc.BadRequest(errors) + + data = serializers.TaskExportSerializer(task.object).data + headers = self.get_success_headers(data) + return response.Created(data, headers=headers) + + @detail_route(methods=['post']) + @method_decorator(atomic) + def issue(self, request, *args, **kwargs): + project = self.get_object_or_none() + self.check_permissions(request, 'import_item', project) + + signals.pre_save.disconnect(sender=Issue, + dispatch_uid="set_finished_date_when_edit_issue") + + issue = services.store.store_issue(project, request.DATA.copy()) + + errors = services.store.get_errors() + if errors: + raise exc.BadRequest(errors) + + data = serializers.IssueExportSerializer(issue.object).data + headers = self.get_success_headers(data) + return response.Created(data, headers=headers) + + @detail_route(methods=['post']) + @method_decorator(atomic) + def wiki_page(self, request, *args, **kwargs): + project = self.get_object_or_none() + self.check_permissions(request, 'import_item', project) + + wiki_page = services.store.store_wiki_page(project, request.DATA.copy()) + + errors = services.store.get_errors() + if errors: + raise exc.BadRequest(errors) + + data = serializers.WikiPageExportSerializer(wiki_page.object).data + headers = self.get_success_headers(data) + return response.Created(data, headers=headers) + + @detail_route(methods=['post']) + @method_decorator(atomic) + def wiki_link(self, request, *args, **kwargs): + project = self.get_object_or_none() + self.check_permissions(request, 'import_item', project) + + wiki_link = services.store.store_wiki_link(project, request.DATA.copy()) + + errors = services.store.get_errors() + if errors: + raise exc.BadRequest(errors) + + data = serializers.WikiLinkExportSerializer(wiki_link.object).data + headers = self.get_success_headers(data) + return response.Created(data, headers=headers) + + @list_route(methods=["POST"]) + @method_decorator(atomic) + def load_dump(self, request): + throttle = throttling.ImportDumpModeRateThrottle() + + if not throttle.allow_request(request, self): + self.throttled(request, throttle.wait()) + + self.check_permissions(request, "load_dump", None) + + dump = request.FILES.get('dump', None) + + if not dump: + raise exc.WrongArguments(_("Needed dump file")) + + if dump.content_type == "application/gzip": + dump = gzip.GzipFile(fileobj=dump) + + reader = codecs.getreader("utf-8") + + try: + dump = json.load(reader(dump)) + + except Exception: + raise exc.WrongArguments(_("Invalid dump format")) + + if not isinstance(dump, dict): + logger.error("trying a load_dump with a different format than dict: {0}, from user {1}".format(dump, request.user)) + raise exc.WrongArguments(_("Invalid dump format")) + + slug = dump.get('slug', None) + if slug is not None and Project.objects.filter(slug=slug).exists(): + del dump['slug'] + + user = request.user + dump['owner'] = user.email + + # Validate if the project can be imported + is_private = dump.get("is_private", False) + memberships = [m["email"] for m in dump.get("memberships", []) if m.get("email", None)] + (enough_slots, error_message, total_memberships) = services.has_available_slot_for_new_project( + user, + is_private, + memberships + ) + if not enough_slots: + raise exc.NotEnoughSlotsForProject(is_private, total_memberships, error_message) + + # Async mode + if settings.CELERY_ENABLED: + task = tasks.load_project_dump.delay(user, dump) + return response.Accepted({"import_id": task.id}) + + # Sync mode + try: + project = services.store_project_from_dict(dump, request.user) + except err.TaigaImportError as e: + # On Error + ## remove project + if e.project: + e.project.delete_related_content() + e.project.delete() + + return response.BadRequest({"error": e.message, "details": e.errors}) + else: + # On Success + project_from_qs = project_utils.attach_extra_info(Project.objects.all()).get(id=project.id) + response_data = ProjectSerializer(project_from_qs).data + + return response.Created(response_data) diff --git a/taiga/export_import/exceptions.py b/taiga/export_import/exceptions.py new file mode 100644 index 000000000..cc6b01cab --- /dev/null +++ b/taiga/export_import/exceptions.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +class TaigaImportError(Exception): + def __init__(self, message, project, errors=[]): + self.message = message + self.project = project + self.errors = errors diff --git a/taiga/export_import/management/__init__.py b/taiga/export_import/management/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/export_import/management/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/export_import/management/commands/__init__.py b/taiga/export_import/management/commands/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/export_import/management/commands/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/export_import/management/commands/dump_project.py b/taiga/export_import/management/commands/dump_project.py new file mode 100644 index 000000000..6b2d63dcf --- /dev/null +++ b/taiga/export_import/management/commands/dump_project.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand, CommandError + +from taiga.projects.models import Project +from taiga.export_import.services import render_project + +import os +import gzip + + +class Command(BaseCommand): + help = "Export projects to a json file" + + def add_arguments(self, parser): + parser.add_argument("project_slugs", + nargs="+", + help="") + + parser.add_argument("-d", "--dst_dir", + action="store", + dest="dst_dir", + default="./", + metavar="DIR", + help="Directory to save the json files. ('./' by default)") + + parser.add_argument("-f", "--format", + action="store", + dest="format", + default="plain", + metavar="[plain|gzip]", + help="Format to the output file plain json or gzipped json. ('plain' by default)") + + def handle(self, *args, **options): + dst_dir = options["dst_dir"] + + if not os.path.exists(dst_dir): + raise CommandError("Directory {} does not exist.".format(dst_dir)) + + if not os.path.isdir(dst_dir): + raise CommandError("'{}' must be a directory, not a file.".format(dst_dir)) + + project_slugs = options["project_slugs"] + + for project_slug in project_slugs: + try: + project = Project.objects.get(slug=project_slug) + except Project.DoesNotExist: + raise CommandError("Project '{}' does not exist".format(project_slug)) + + if options["format"] == "gzip": + dst_file = os.path.join(dst_dir, "{}.json.gz".format(project_slug)) + with gzip.GzipFile(dst_file, "wb") as f: + render_project(project, f) + else: + dst_file = os.path.join(dst_dir, "{}.json".format(project_slug)) + with open(dst_file, "wb") as f: + render_project(project, f) + + print("-> Generate dump of project '{}' in '{}'".format(project.name, dst_file)) diff --git a/taiga/export_import/management/commands/dump_project_async.py b/taiga/export_import/management/commands/dump_project_async.py new file mode 100644 index 000000000..ef31b24af --- /dev/null +++ b/taiga/export_import/management/commands/dump_project_async.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Q +from django.conf import settings + +from taiga.projects.models import Project +from taiga.users.models import User +from taiga.permissions.services import is_project_admin +from taiga.export_import import tasks + + +class Command(BaseCommand): + help = "Export projects to a json file" + + def add_arguments(self, parser): + parser.add_argument("project_slugs", + nargs="+", + help="") + + parser.add_argument("-u", "--user", + action="store", + dest="user", + default="./", + metavar="DIR", + required=True, + help="Dump as user by email or username.") + + parser.add_argument("-f", "--format", + action="store", + dest="format", + default="plain", + metavar="[plain|gzip]", + help="Format to the output file plain json or gzipped json. ('plain' by default)") + + def handle(self, *args, **options): + username_or_email = options["user"] + dump_format = options["format"] + project_slugs = options["project_slugs"] + + try: + user = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email)) + except Exception: + raise CommandError("Error loading user".format(username_or_email)) + + for project_slug in project_slugs: + try: + project = Project.objects.get(slug=project_slug) + except Project.DoesNotExist: + raise CommandError("Project '{}' does not exist".format(project_slug)) + + if not is_project_admin(user, project): + self.stderr.write(self.style.ERROR( + "ERROR: Not sending task because user '{}' doesn't have permissions to export '{}' project".format( + username_or_email, + project_slug + ) + )) + continue + + task = tasks.dump_project.delay(user, project, dump_format) + tasks.delete_project_dump.apply_async( + (project.pk, project.slug, task.id, dump_format), + countdown=settings.EXPORTS_TTL + ) + print("-> Sent task for dump of project '{}' as user {}".format(project.name, username_or_email)) diff --git a/taiga/export_import/management/commands/load_dump.py b/taiga/export_import/management/commands/load_dump.py new file mode 100644 index 000000000..09a771c4c --- /dev/null +++ b/taiga/export_import/management/commands/load_dump.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import signals + +from taiga.base.utils import json +from taiga.export_import import services +from taiga.export_import import exceptions as err +from taiga.projects.models import Project +from taiga.users.models import User + + +class Command(BaseCommand): + help = 'Import a project from a json file' + + def add_arguments(self, parser): + parser.add_argument("dump_file", + help="The path to a dump file (.json).") + + parser.add_argument("owner_email", + help="The email of the new project owner.") + + parser.add_argument("-o", '--overwrite', + action='store_true', + dest='overwrite', + default=False, + help='Overwrite the project if exists') + + def handle(self, *args, **options): + dump_file_path = options["dump_file"] + owner_email = options["owner_email"] + overwrite = options["overwrite"] + + data = json.loads(open(dump_file_path, 'r').read()) + try: + if overwrite: + receivers_back = signals.post_delete.receivers + signals.post_delete.receivers = [] + try: + proj = Project.objects.get(slug=data.get("slug", "not a slug")) + proj.tasks.all().delete() + proj.user_stories.all().delete() + proj.issues.all().delete() + proj.memberships.all().delete() + proj.roles.all().delete() + proj.delete() + except Project.DoesNotExist: + pass + signals.post_delete.receivers = receivers_back + else: + slug = data.get('slug', None) + if slug is not None and Project.objects.filter(slug=slug).exists(): + del data['slug'] + + user = User.objects.get(email=owner_email) + services.store_project_from_dict(data, user) + except err.TaigaImportError as e: + if e.project: + e.project.delete_related_content() + e.project.delete() + + print("ERROR:", end=" ") + print(e.message) + print(json.dumps(e.errors, indent=4)) diff --git a/taiga/export_import/mixins.py b/taiga/export_import/mixins.py new file mode 100644 index 000000000..793e47cbb --- /dev/null +++ b/taiga/export_import/mixins.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from . import throttling + + +class ImportThrottlingPolicyMixin: + throttle_classes = (throttling.ImportModeRateThrottle,) diff --git a/taiga/export_import/models.py b/taiga/export_import/models.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/export_import/models.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/export_import/permissions.py b/taiga/export_import/permissions.py new file mode 100644 index 000000000..4e2910c86 --- /dev/null +++ b/taiga/export_import/permissions.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (TaigaResourcePermission, + IsProjectAdmin, IsAuthenticated) + + +class ImportExportPermission(TaigaResourcePermission): + import_project_perms = IsAuthenticated() + import_item_perms = IsProjectAdmin() + export_project_perms = IsProjectAdmin() + load_dump_perms = IsAuthenticated() diff --git a/taiga/export_import/renderers.py b/taiga/export_import/renderers.py new file mode 100644 index 000000000..f30f8a89b --- /dev/null +++ b/taiga/export_import/renderers.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.renderers import UnicodeJSONRenderer + + +class ExportRenderer(UnicodeJSONRenderer): + pass diff --git a/taiga/export_import/serializers/__init__.py b/taiga/export_import/serializers/__init__.py new file mode 100644 index 000000000..662a311ee --- /dev/null +++ b/taiga/export_import/serializers/__init__.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from .serializers import PointsExportSerializer +from .serializers import UserStoryStatusExportSerializer +from .serializers import TaskStatusExportSerializer +from .serializers import IssueStatusExportSerializer +from .serializers import PriorityExportSerializer +from .serializers import SeverityExportSerializer +from .serializers import SwimlaneExportSerializer +from .serializers import IssueTypeExportSerializer +from .serializers import RoleExportSerializer +from .serializers import UserStoryCustomAttributeExportSerializer +from .serializers import TaskCustomAttributeExportSerializer +from .serializers import IssueCustomAttributeExportSerializer +from .serializers import BaseCustomAttributesValuesExportSerializer +from .serializers import UserStoryCustomAttributesValuesExportSerializer +from .serializers import TaskCustomAttributesValuesExportSerializer +from .serializers import IssueCustomAttributesValuesExportSerializer +from .serializers import MembershipExportSerializer +from .serializers import RolePointsExportSerializer +from .serializers import MilestoneExportSerializer +from .serializers import TaskExportSerializer +from .serializers import UserStoryExportSerializer +from .serializers import IssueExportSerializer +from .serializers import WikiPageExportSerializer +from .serializers import WikiLinkExportSerializer +from .serializers import TimelineExportSerializer +from .serializers import ProjectExportSerializer +from .mixins import AttachmentExportSerializer +from .mixins import HistoryExportSerializer diff --git a/taiga/export_import/serializers/cache.py b/taiga/export_import/serializers/cache.py new file mode 100644 index 000000000..ddb968e0b --- /dev/null +++ b/taiga/export_import/serializers/cache.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.users import models as users_models + +_cache_user_by_pk = {} +_cache_user_by_email = {} +_custom_tasks_attributes_cache = {} +_custom_issues_attributes_cache = {} +_custom_userstories_attributes_cache = {} +_custom_epics_attributes_cache = {} +_tasks_statuses_cache = {} +_issues_statuses_cache = {} +_userstories_statuses_cache = {} +_epics_statuses_cache = {} + + +def cached_get_user_by_pk(pk): + if pk not in _cache_user_by_pk: + try: + _cache_user_by_pk[pk] = users_models.User.objects.get(pk=pk) + except Exception: + _cache_user_by_pk[pk] = users_models.User.objects.get(pk=pk) + return _cache_user_by_pk[pk] + + +def cached_get_user_by_email(email): + if email not in _cache_user_by_email: + try: + _cache_user_by_email[email] = users_models.User.objects.get(email=email) + except Exception: + _cache_user_by_email[email] = users_models.User.objects.get(email=email) + return _cache_user_by_email[email] diff --git a/taiga/export_import/serializers/fields.py b/taiga/export_import/serializers/fields.py new file mode 100644 index 000000000..e23d9629d --- /dev/null +++ b/taiga/export_import/serializers/fields.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import base64 +import logging +import os +import sys +import copy +from collections import OrderedDict + +from taiga.base.fields import Field +from taiga.users import models as users_models + +from .cache import cached_get_user_by_pk + + +logger = logging.getLogger(__name__) + + +class FileField(Field): + def to_value(self, obj): + if not obj: + return None + + try: + read_file = obj.read() + except UnicodeEncodeError: + logger.error("UnicodeEncodeError in %s", obj.name, + exc_info=sys.exc_info()) + data = "" + else: + data = base64.b64encode(read_file).decode('utf-8') + + return OrderedDict([ + ("data", data), + ("name", os.path.basename(obj.name)), + ]) + + +class ContentTypeField(Field): + def to_value(self, obj): + if obj: + return [obj.app_label, obj.model] + return None + + +class UserRelatedField(Field): + def to_value(self, obj): + if obj: + return obj.email + return None + + +class UserPkField(Field): + def to_value(self, obj): + try: + user = cached_get_user_by_pk(obj) + return user.email + except Exception: + return None + + +class SlugRelatedField(Field): + def __init__(self, slug_field, *args, **kwargs): + self.slug_field = slug_field + super().__init__(*args, **kwargs) + + def to_value(self, obj): + if obj: + return getattr(obj, self.slug_field) + return None + + +class HistoryUserField(Field): + def to_value(self, obj): + if obj is None or obj == {}: + return [] + try: + user = cached_get_user_by_pk(obj['pk']) + except users_models.User.DoesNotExist: + user = None + return (UserRelatedField().to_value(user), obj['name']) + + +class HistoryValuesField(Field): + def to_value(self, obj): + if obj is None: + return [] + if "users" in obj: + obj['users'] = list(map(UserPkField().to_value, obj['users'])) + return obj + + +class HistoryDiffField(Field): + def to_value(self, obj): + if obj is None: + return [] + + if "assigned_to" in obj: + obj['assigned_to'] = list(map(UserPkField().to_value, obj['assigned_to'])) + + return obj + + +class TimelineDataField(Field): + def to_value(self, data): + new_data = copy.deepcopy(data) + try: + user = cached_get_user_by_pk(new_data["user"]["id"]) + new_data["user"]["email"] = user.email + del new_data["user"]["id"] + except Exception: + pass + return new_data diff --git a/taiga/export_import/serializers/mixins.py b/taiga/export_import/serializers/mixins.py new file mode 100644 index 000000000..4b72b1b0f --- /dev/null +++ b/taiga/export_import/serializers/mixins.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField, DateTimeField +from taiga.projects.history import models as history_models +from taiga.projects.attachments import models as attachments_models +from taiga.projects.history import services as history_service + +from .cache import cached_get_user_by_email, cached_get_user_by_pk +from .fields import (UserRelatedField, HistoryUserField, + HistoryDiffField, HistoryValuesField, FileField) + + +class HistoryExportSerializer(serializers.LightSerializer): + user = HistoryUserField() + diff = HistoryDiffField() + snapshot = MethodField() + values = HistoryValuesField() + comment = Field() + delete_comment_date = DateTimeField() + delete_comment_user = HistoryUserField() + comment_versions = Field() + created_at = DateTimeField() + edit_comment_date = DateTimeField() + is_hidden = Field() + is_snapshot = Field() + type = Field() + + def __init__(self, *args, **kwargs): + # Don't pass the extra ids args up to the superclass + self.statuses_queryset = kwargs.pop("statuses_queryset", {}) + + # Instantiate the superclass normally + super().__init__(*args, **kwargs) + + def get_snapshot(self, obj): + user_model_cls = get_user_model() + + snapshot = obj.snapshot + if snapshot is None: + return None + + try: + owner_field = snapshot.get("owner", None) + if isinstance(owner_field, int): + owner = cached_get_user_by_pk(owner_field) + else: + owner = cached_get_user_by_email(owner_field) + snapshot["owner"] = owner.email + except user_model_cls.DoesNotExist: + pass + + try: + assigned_to_field = snapshot.get("assigned_to", None) + if isinstance(assigned_to_field, int): + assigned_to = cached_get_user_by_pk(assigned_to_field) + else: + assigned_to = cached_get_user_by_email(assigned_to_field) + snapshot["assigned_to"] = assigned_to.email + except user_model_cls.DoesNotExist: + pass + + if "status" in snapshot: + snapshot["status"] = self.statuses_queryset.get(snapshot["status"]) + + return snapshot + + +class HistoryExportSerializerMixin(serializers.LightSerializer): + history = MethodField("get_history") + + def statuses_queryset(self, project): + raise NotImplementedError() + + def get_history(self, obj): + history_qs = history_service.get_history_queryset_by_model_instance( + obj, + types=(history_models.HistoryType.change, history_models.HistoryType.create,) + ) + return HistoryExportSerializer(history_qs, many=True, + statuses_queryset=self.statuses_queryset(obj.project)).data + + +class AttachmentExportSerializer(serializers.LightSerializer): + owner = UserRelatedField() + attached_file = FileField() + created_date = DateTimeField() + modified_date = DateTimeField() + description = Field() + is_deprecated = Field() + name = Field() + order = Field() + sha1 = Field() + size = Field() + + +class AttachmentExportSerializerMixin(serializers.LightSerializer): + attachments = MethodField() + + def get_attachments(self, obj): + content_type = ContentType.objects.get_for_model(obj.__class__) + attachments_qs = attachments_models.Attachment.objects.filter(object_id=obj.pk, + content_type=content_type) + return AttachmentExportSerializer(attachments_qs, many=True).data + + +class CustomAttributesValuesExportSerializerMixin(serializers.LightSerializer): + custom_attributes_values = MethodField("get_custom_attributes_values") + + def custom_attributes_queryset(self, project): + raise NotImplementedError() + + def get_custom_attributes_values(self, obj): + def _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values): + ret = {} + for attr in custom_attributes: + value = values.get(str(attr["id"]), None) + if value is not None: + ret[attr["name"]] = value + + return ret + + try: + values = obj.custom_attributes_values.attributes_values + custom_attributes = self.custom_attributes_queryset(obj.project) + + return _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values) + except ObjectDoesNotExist: + return None + + +class WatcheableObjectLightSerializerMixin(serializers.LightSerializer): + watchers = MethodField() + + def get_watchers(self, obj): + return [user.email for user in obj.get_watchers()] diff --git a/taiga/export_import/serializers/serializers.py b/taiga/export_import/serializers/serializers.py new file mode 100644 index 000000000..983c4c884 --- /dev/null +++ b/taiga/export_import/serializers/serializers.py @@ -0,0 +1,503 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, DateTimeField, MethodField + +from taiga.projects.votes import services as votes_service + +from .fields import (FileField, UserRelatedField, TimelineDataField, + ContentTypeField, SlugRelatedField) +from .mixins import (HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, + CustomAttributesValuesExportSerializerMixin, + WatcheableObjectLightSerializerMixin) +from .cache import (_custom_tasks_attributes_cache, + _custom_userstories_attributes_cache, + _custom_epics_attributes_cache, + _custom_issues_attributes_cache, + _tasks_statuses_cache, + _issues_statuses_cache, + _userstories_statuses_cache, + _epics_statuses_cache) + + +class RelatedExportSerializer(serializers.LightSerializer): + def to_value(self, value): + if hasattr(value, 'all'): + return super().to_value(value.all()) + return super().to_value(value) + + +class PointsExportSerializer(RelatedExportSerializer): + name = Field() + order = Field() + value = Field() + + +class UserStoryStatusExportSerializer(RelatedExportSerializer): + name = Field() + slug = Field() + order = Field() + is_closed = Field() + is_archived = Field() + color = Field() + wip_limit = Field() + + +class UserStoryDueDateExportSerializer(RelatedExportSerializer): + name = Field() + order = Field() + by_default = Field() + color = Field() + days_to_due = Field() + + +class EpicStatusExportSerializer(RelatedExportSerializer): + name = Field() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + + +class TaskStatusExportSerializer(RelatedExportSerializer): + name = Field() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + + +class TaskDueDateExportSerializer(RelatedExportSerializer): + name = Field() + order = Field() + by_default = Field() + color = Field() + days_to_due = Field() + + +class IssueStatusExportSerializer(RelatedExportSerializer): + name = Field() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + + +class IssueDueDateExportSerializer(RelatedExportSerializer): + name = Field() + order = Field() + by_default = Field() + color = Field() + days_to_due = Field() + + +class PriorityExportSerializer(RelatedExportSerializer): + name = Field() + order = Field() + color = Field() + + +class SeverityExportSerializer(RelatedExportSerializer): + name = Field() + order = Field() + color = Field() + + +class IssueTypeExportSerializer(RelatedExportSerializer): + name = Field() + order = Field() + color = Field() + + +class SwimlaneUserStoryStatusExportSerializer(RelatedExportSerializer): + status = SlugRelatedField(slug_field="name") + wip_limit = Field() + + +class SwimlaneExportSerializer(RelatedExportSerializer): + name = Field() + order = Field() + statuses = SwimlaneUserStoryStatusExportSerializer(many=True) + + +class RoleExportSerializer(RelatedExportSerializer): + name = Field() + slug = Field() + order = Field() + computable = Field() + permissions = Field() + + +class EpicCustomAttributesExportSerializer(RelatedExportSerializer): + name = Field() + description = Field() + type = Field() + order = Field() + created_date = DateTimeField() + modified_date = DateTimeField() + + +class UserStoryCustomAttributeExportSerializer(RelatedExportSerializer): + name = Field() + description = Field() + type = Field() + order = Field() + created_date = DateTimeField() + modified_date = DateTimeField() + + +class TaskCustomAttributeExportSerializer(RelatedExportSerializer): + name = Field() + description = Field() + type = Field() + order = Field() + created_date = DateTimeField() + modified_date = DateTimeField() + + +class IssueCustomAttributeExportSerializer(RelatedExportSerializer): + name = Field() + description = Field() + type = Field() + order = Field() + created_date = DateTimeField() + modified_date = DateTimeField() + + +class BaseCustomAttributesValuesExportSerializer(RelatedExportSerializer): + attributes_values = Field(required=True) + + +class UserStoryCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): + user_story = Field(attr="user_story.id") + + +class TaskCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): + task = Field(attr="task.id") + + +class IssueCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): + issue = Field(attr="issue.id") + + +class MembershipExportSerializer(RelatedExportSerializer): + user = UserRelatedField() + role = SlugRelatedField(slug_field="name") + invited_by = UserRelatedField() + is_admin = Field() + email = Field() + created_at = DateTimeField() + invitation_extra_text = Field() + user_order = Field() + + +class RolePointsExportSerializer(RelatedExportSerializer): + role = SlugRelatedField(slug_field="name") + points = SlugRelatedField(slug_field="name") + + +class MilestoneExportSerializer(WatcheableObjectLightSerializerMixin, RelatedExportSerializer): + name = Field() + owner = UserRelatedField() + created_date = DateTimeField() + modified_date = DateTimeField() + estimated_start = Field() + estimated_finish = Field() + slug = Field() + closed = Field() + disponibility = Field() + order = Field() + + +class TaskExportSerializer(CustomAttributesValuesExportSerializerMixin, + HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, + WatcheableObjectLightSerializerMixin, + RelatedExportSerializer): + owner = UserRelatedField() + status = SlugRelatedField(slug_field="name") + user_story = SlugRelatedField(slug_field="ref") + milestone = SlugRelatedField(slug_field="name") + assigned_to = UserRelatedField() + modified_date = DateTimeField() + created_date = DateTimeField() + finished_date = DateTimeField() + ref = Field() + subject = Field() + us_order = Field() + taskboard_order = Field() + description = Field() + is_iocaine = Field() + external_reference = Field() + version = Field() + blocked_note = Field() + is_blocked = Field() + tags = Field() + due_date = DateTimeField() + due_date_reason = Field() + + def custom_attributes_queryset(self, project): + if project.id not in _custom_tasks_attributes_cache: + _custom_tasks_attributes_cache[project.id] = list(project.taskcustomattributes.all().values('id', 'name')) + return _custom_tasks_attributes_cache[project.id] + + def statuses_queryset(self, project): + if project.id not in _tasks_statuses_cache: + _tasks_statuses_cache[project.id] = {s.id: s.name for s in project.task_statuses.all()} + return _tasks_statuses_cache[project.id] + + +class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, + HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, + WatcheableObjectLightSerializerMixin, + RelatedExportSerializer): + role_points = RolePointsExportSerializer(many=True) + owner = UserRelatedField() + assigned_to = UserRelatedField() + assigned_users = MethodField() + status = SlugRelatedField(slug_field="name") + swimlane = SlugRelatedField(slug_field="name") + milestone = SlugRelatedField(slug_field="name") + modified_date = DateTimeField() + created_date = DateTimeField() + finish_date = DateTimeField() + generated_from_issue = SlugRelatedField(slug_field="ref") + generated_from_task = SlugRelatedField(slug_field="ref") + from_task_ref = Field() + ref = Field() + is_closed = Field() + backlog_order = Field() + sprint_order = Field() + kanban_order = Field() + subject = Field() + description = Field() + client_requirement = Field() + team_requirement = Field() + external_reference = Field() + tribe_gig = Field() + version = Field() + blocked_note = Field() + is_blocked = Field() + tags = Field() + due_date = DateTimeField() + due_date_reason = Field() + + def custom_attributes_queryset(self, project): + if project.id not in _custom_userstories_attributes_cache: + _custom_userstories_attributes_cache[project.id] = list( + project.userstorycustomattributes.all().values('id', 'name') + ) + return _custom_userstories_attributes_cache[project.id] + + def statuses_queryset(self, project): + if project.id not in _userstories_statuses_cache: + _userstories_statuses_cache[project.id] = {s.id: s.name for s in project.us_statuses.all()} + return _userstories_statuses_cache[project.id] + + def get_assigned_users(self, obj): + return [user.email for user in obj.assigned_users.all()] + + +class EpicRelatedUserStoryExportSerializer(RelatedExportSerializer): + user_story = SlugRelatedField(slug_field="ref") + order = Field() + source_project_slug = MethodField() + + def get_source_project_slug(self, obj): + if obj.epic.project.slug != obj.user_story.project.slug: + return obj.user_story.project.slug + + return None + + +class EpicExportSerializer(CustomAttributesValuesExportSerializerMixin, + HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, + WatcheableObjectLightSerializerMixin, + RelatedExportSerializer): + ref = Field() + owner = UserRelatedField() + status = SlugRelatedField(slug_field="name") + epics_order = Field() + created_date = DateTimeField() + modified_date = DateTimeField() + subject = Field() + description = Field() + color = Field() + assigned_to = UserRelatedField() + client_requirement = Field() + team_requirement = Field() + version = Field() + blocked_note = Field() + is_blocked = Field() + tags = Field() + related_user_stories = MethodField() + + def get_related_user_stories(self, obj): + return EpicRelatedUserStoryExportSerializer(obj.relateduserstory_set.filter(epic__project=obj.project), many=True).data + + def custom_attributes_queryset(self, project): + if project.id not in _custom_epics_attributes_cache: + _custom_epics_attributes_cache[project.id] = list( + project.epiccustomattributes.all().values('id', 'name') + ) + return _custom_epics_attributes_cache[project.id] + + def statuses_queryset(self, project): + if project.id not in _epics_statuses_cache: + _epics_statuses_cache[project.id] = {s.id: s.name for s in project.epic_statuses.all()} + return _epics_statuses_cache[project.id] + + +class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, + HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, + WatcheableObjectLightSerializerMixin, + RelatedExportSerializer): + owner = UserRelatedField() + status = SlugRelatedField(slug_field="name") + assigned_to = UserRelatedField() + priority = SlugRelatedField(slug_field="name") + severity = SlugRelatedField(slug_field="name") + type = SlugRelatedField(slug_field="name") + milestone = SlugRelatedField(slug_field="name") + votes = MethodField("get_votes") + modified_date = DateTimeField() + created_date = DateTimeField() + finished_date = DateTimeField() + + ref = Field() + subject = Field() + description = Field() + external_reference = Field() + version = Field() + blocked_note = Field() + is_blocked = Field() + tags = Field() + + due_date = DateTimeField() + due_date_reason = Field() + + def get_votes(self, obj): + return [x.email for x in votes_service.get_voters(obj)] + + def custom_attributes_queryset(self, project): + if project.id not in _custom_issues_attributes_cache: + _custom_issues_attributes_cache[project.id] = list(project.issuecustomattributes.all().values('id', 'name')) + return _custom_issues_attributes_cache[project.id] + + def statuses_queryset(self, project): + if project.id not in _issues_statuses_cache: + _issues_statuses_cache[project.id] = {s.id: s.name for s in project.issue_statuses.all()} + return _issues_statuses_cache[project.id] + +class WikiPageExportSerializer(HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, + WatcheableObjectLightSerializerMixin, + RelatedExportSerializer): + slug = Field() + owner = UserRelatedField() + last_modifier = UserRelatedField() + modified_date = DateTimeField() + created_date = DateTimeField() + content = Field() + version = Field() + + def statuses_queryset(self, project): + return {} + +class WikiLinkExportSerializer(RelatedExportSerializer): + title = Field() + href = Field() + order = Field() + + +class TimelineExportSerializer(RelatedExportSerializer): + data = TimelineDataField() + data_content_type = ContentTypeField() + event_type = Field() + created = DateTimeField() + + +class ProjectExportSerializer(WatcheableObjectLightSerializerMixin): + name = Field() + slug = Field() + description = Field() + created_date = DateTimeField() + logo = FileField() + total_milestones = Field() + total_story_points = Field() + is_epics_activated = Field() + is_backlog_activated = Field() + is_kanban_activated = Field() + is_wiki_activated = Field() + is_issues_activated = Field() + videoconferences = Field() + videoconferences_extra_data = Field() + creation_template = SlugRelatedField(slug_field="slug") + is_private = Field() + is_featured = Field() + is_looking_for_people = Field() + looking_for_people_note = Field() + epics_csv_uuid = Field() + userstories_csv_uuid = Field() + tasks_csv_uuid = Field() + issues_csv_uuid = Field() + transfer_token = Field() + blocked_code = Field() + totals_updated_datetime = DateTimeField() + total_fans = Field() + total_fans_last_week = Field() + total_fans_last_month = Field() + total_fans_last_year = Field() + total_activity = Field() + total_activity_last_week = Field() + total_activity_last_month = Field() + total_activity_last_year = Field() + anon_permissions = Field() + public_permissions = Field() + modified_date = DateTimeField() + roles = RoleExportSerializer(many=True) + owner = UserRelatedField() + memberships = MembershipExportSerializer(many=True) + points = PointsExportSerializer(many=True) + epic_statuses = EpicStatusExportSerializer(many=True) + us_statuses = UserStoryStatusExportSerializer(many=True) + us_duedates = UserStoryDueDateExportSerializer(many=True) + task_statuses = TaskStatusExportSerializer(many=True) + task_duedates = TaskDueDateExportSerializer(many=True) + issue_types = IssueTypeExportSerializer(many=True) + issue_statuses = IssueStatusExportSerializer(many=True) + issue_duedates = IssueDueDateExportSerializer(many=True) + priorities = PriorityExportSerializer(many=True) + severities = SeverityExportSerializer(many=True) + swimlanes = SwimlaneExportSerializer(many=True) + tags_colors = Field() + default_points = SlugRelatedField(slug_field="name") + default_epic_status = SlugRelatedField(slug_field="name") + default_us_status = SlugRelatedField(slug_field="name") + default_task_status = SlugRelatedField(slug_field="name") + default_priority = SlugRelatedField(slug_field="name") + default_severity = SlugRelatedField(slug_field="name") + default_issue_status = SlugRelatedField(slug_field="name") + default_issue_type = SlugRelatedField(slug_field="name") + default_swimlane = SlugRelatedField(slug_field="name") + epiccustomattributes = EpicCustomAttributesExportSerializer(many=True) + userstorycustomattributes = UserStoryCustomAttributeExportSerializer(many=True) + taskcustomattributes = TaskCustomAttributeExportSerializer(many=True) + issuecustomattributes = IssueCustomAttributeExportSerializer(many=True) + epics = EpicExportSerializer(many=True) + user_stories = UserStoryExportSerializer(many=True) + tasks = TaskExportSerializer(many=True) + milestones = MilestoneExportSerializer(many=True) + issues = IssueExportSerializer(many=True) + wiki_links = WikiLinkExportSerializer(many=True) + wiki_pages = WikiPageExportSerializer(many=True) + tags = Field() diff --git a/taiga/export_import/services/__init__.py b/taiga/export_import/services/__init__.py new file mode 100644 index 000000000..1808b7df7 --- /dev/null +++ b/taiga/export_import/services/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# This makes all code that import services works and +# is not the baddest practice ;) + +from .render import render_project +from . import render + +from .store import store_project_from_dict +from . import store + +from .validations import has_available_slot_for_new_project +from . import validations diff --git a/taiga/export_import/services/render.py b/taiga/export_import/services/render.py new file mode 100644 index 000000000..b4837b715 --- /dev/null +++ b/taiga/export_import/services/render.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# This makes all code that import services works and +# is not the baddest practice ;) + +import gc + +from taiga.base.utils import json +from taiga.base.fields import MethodField +from taiga.timeline.service import get_project_timeline +from taiga.base.api.fields import get_component + +from .. import serializers + + +def render_project(project, outfile, chunk_size=8190): + serializer = serializers.ProjectExportSerializer(project) + outfile.write(b'{\n') + + first_field = True + for field_name in serializer._field_map.keys(): + # Avoid writing "," in the last element + if not first_field: + outfile.write(b",\n") + else: + first_field = False + + field = serializer._field_map.get(field_name) + # field.initialize(parent=serializer, field_name=field_name) + + # These four "special" fields hava attachments so we use them in a special way + if field_name in ["wiki_pages", "user_stories", "tasks", "issues", "epics"]: + value = get_component(project, field_name) + if field_name != "wiki_pages": + value = value.select_related('owner', 'status', + 'project', 'assigned_to', + 'custom_attributes_values') + + if field_name in ["user_stories", "tasks", "issues"]: + value = value.select_related('milestone') + + if field_name == "issues": + value = value.select_related('severity', 'priority', 'type') + value = value.prefetch_related('history_entry', 'attachments') + + outfile.write('"{}": [\n'.format(field_name).encode()) + + first_item = True + for item in value.iterator(): + # Avoid writing "," in the last element + if not first_item: + outfile.write(b",\n") + else: + first_item = False + + field.many = False + dumped_value = json.dumps(field.to_value(item)) + outfile.write(dumped_value.encode()) + outfile.flush() + gc.collect() + outfile.write(b']') + else: + if isinstance(field, MethodField): + value = field.as_getter(field_name, serializers.ProjectExportSerializer)(serializer, project) + else: + attr = getattr(project, field_name) + value = field.to_value(attr) + outfile.write('"{}": {}'.format(field_name, json.dumps(value)).encode()) + + # Generate the timeline + outfile.write(b',\n"timeline": [\n') + first_timeline = True + for timeline_item in get_project_timeline(project).iterator(): + # Avoid writing "," in the last element + if not first_timeline: + outfile.write(b",\n") + else: + first_timeline = False + + dumped_value = json.dumps(serializers.TimelineExportSerializer(timeline_item).data) + outfile.write(dumped_value.encode()) + + outfile.write(b']}\n') diff --git a/taiga/export_import/services/store.py b/taiga/export_import/services/store.py new file mode 100644 index 000000000..fd778b430 --- /dev/null +++ b/taiga/export_import/services/store.py @@ -0,0 +1,930 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# This makes all code that import services works and +# is not the baddest practice ;) + +import os +import uuid + +from unidecode import unidecode + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.db import utils +from django.template.defaultfilters import slugify +from django.utils.translation import gettext as _ + +from taiga.projects.history.services import make_key_from_model_object, take_snapshot +from taiga.projects.models import Membership +from taiga.projects.references import sequences as seq +from taiga.projects.references import models as refs +from taiga.projects.userstories.models import RolePoints +from taiga.projects.services import find_invited_user +from taiga.timeline.service import build_project_namespace + +from .. import exceptions as err +from .. import validators +from .. import services + +import logging +logger = logging.getLogger('taiga.export_import') + +######################################################################## +## Manage errors +######################################################################## + +_errors_log = {} + + +def get_errors(clear=True): + _errors = _errors_log.copy() + if clear: + _errors_log.clear() + return _errors + + +def add_errors(section, errors): + if section in _errors_log: + _errors_log[section].append(errors) + else: + _errors_log[section] = [errors] + + +def reset_errors(): + _errors_log.clear() + + +######################################################################## +## Store functions +######################################################################## + + +## PROJECT + +def store_project(data): + project_data = {} + for key, value in data.items(): + excluded_fields = [ + "default_points", "default_us_status", "default_task_status", + "default_priority", "default_severity", "default_issue_status", + "default_issue_type", "default_epic_status", "default_swimlane", + "memberships", "points", + "epic_statuses", "us_statuses", "task_statuses", "issue_statuses", + "priorities", "severities", "swimlanes", + "issue_types", + "epiccustomattributes", "userstorycustomattributes", + "taskcustomattributes", "issuecustomattributes", + "roles", "milestones", + "wiki_pages", "wiki_links", + "notify_policies", + "epics", "user_stories", "issues", "tasks", + "is_featured" + ] + if key not in excluded_fields: + project_data[key] = value + + validator = validators.ProjectExportValidator(data=project_data) + if validator.is_valid(): + validator.object._importing = True + validator.object.save() + validator.save_watchers() + return validator + add_errors("project", validator.errors) + return None + + +## MISC + +def _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, values): + ret = {} + for attr in custom_attributes: + value = values.get(attr["name"], None) + if value is not None: + ret[str(attr["id"])] = value + + return ret + + +def _store_custom_attributes_values(obj, data_values, obj_field, serializer_class): + data = { + obj_field: obj.id, + "attributes_values": data_values, + } + + try: + custom_attributes_values = obj.custom_attributes_values + serializer = serializer_class(custom_attributes_values, data=data) + except ObjectDoesNotExist: + serializer = serializer_class(data=data) + + if serializer.is_valid(): + serializer.save() + return serializer + + add_errors("custom_attributes_values", serializer.errors) + return None + + +def _store_attachment(project, obj, attachment): + validator = validators.AttachmentExportValidator(data=attachment) + if validator.is_valid(): + validator.object.content_type = ContentType.objects.get_for_model(obj.__class__) + validator.object.object_id = obj.id + validator.object.project = project + if validator.object.owner is None: + validator.object.owner = validator.object.project.owner + validator.object._importing = True + validator.object.size = validator.object.attached_file.size + validator.object.name = os.path.basename(validator.object.attached_file.name) + validator.save() + return validator + add_errors("attachments", validator.errors) + return validator + + +def _store_history(project, obj, history, statuses={}): + validator = validators.HistoryExportValidator(data=history, context={"project": project, "statuses": statuses}) + if validator.is_valid(): + validator.object.key = make_key_from_model_object(obj) + if validator.object.diff is None: + validator.object.diff = [] + validator.object.project_id = project.id + validator.object._importing = True + validator.save() + return validator + add_errors("history", validator.errors) + return validator + + +## ROLES + +def _store_role(project, role): + validator = validators.RoleExportValidator(data=role) + if validator.is_valid(): + validator.object.project = project + validator.object._importing = True + validator.save() + return validator + add_errors("roles", validator.errors) + return None + + +def store_roles(project, data): + results = [] + for role in data.get("roles", []): + validator = _store_role(project, role) + if validator: + results.append(validator) + + return results + + +## MEMGERSHIPS + +def _store_membership(project, membership): + validator = validators.MembershipExportValidator(data=membership, context={"project": project}) + if validator.is_valid(): + validator.object.project = project + validator.object._importing = True + validator.object.token = str(uuid.uuid1()) + validator.object.user = find_invited_user(validator.object.email, + default=validator.object.user) + try: + validator.save() + except utils.IntegrityError: + # Avoid import errors when the project has duplicated invitations + return + return validator + + add_errors("memberships", validator.errors) + return None + + +def store_memberships(project, data): + results = [] + for membership in data.get("memberships", []): + member = _store_membership(project, membership) + if member: + results.append(member) + return results + + +## PROJECT ATTRIBUTES + +def _store_project_attribute_value(project, data, field, serializer): + validator = serializer(data=data) + if validator.is_valid(): + validator.object.project = project + validator.object._importing = True + validator.save() + return validator.object + + add_errors(field, validator.errors) + return None + + +def store_project_attributes_values(project, data, field, serializer): + result = [] + for choice_data in data.get(field, []): + result.append(_store_project_attribute_value(project, choice_data, field, + serializer)) + return result + + +## SWIMLANES + +def _store_swimlane_userstory_status(project, swimlane, data): + validator = validators.SwimlaneUserStoryStatusExportValidator(data=data, context={"project": project}) + if validator.is_valid(): + validator.object.swimlane = swimlane + validator.save() + return validator.object + + add_errors("statuses", validator.errors) + return None + + +def store_swimlane(project, data): + swimlane_data = {key: value for key, value in data.items() if key not in ("statuses",)} + + validator = validators.SwimlaneExportValidator(data=swimlane_data, context={"project": project}) + + if validator.is_valid(): + validator.object.project = project + validator.object._importing = True + + validator.save() + + for status in data.get("statuses", []): + _store_swimlane_userstory_status(project, validator.object, status) + + return validator + + add_errors("swimlanes", validator.errors) + return None + + +def store_swimlanes(project, data): + results = [] + for swimlane_data in data.get("swimlanes", []): + results.append(store_swimlane(project, swimlane_data)) + return results + + +## DEFAULT PROJECT ATTRIBUTES VALUES + +def store_default_project_attributes_values(project, data): + def helper(project, field, related, data): + if field in data: + name = data[field] + if name: + value = related.all().get(name=name) + else: + value = None + else: + value = related.all().first() + setattr(project, field, value) + helper(project, "default_points", project.points, data) + helper(project, "default_issue_type", project.issue_types, data) + helper(project, "default_issue_status", project.issue_statuses, data) + helper(project, "default_epic_status", project.epic_statuses, data) + helper(project, "default_us_status", project.us_statuses, data) + helper(project, "default_task_status", project.task_statuses, data) + helper(project, "default_priority", project.priorities, data) + helper(project, "default_severity", project.severities, data) + helper(project, "default_swimlane", project.swimlanes, data) + project._importing = True + project.save() + + +## CUSTOM ATTRIBUTES + +def _store_custom_attribute(project, data, field, serializer): + validator = serializer(data=data) + if validator.is_valid(): + validator.object.project = project + validator.object._importing = True + validator.save() + return validator.object + add_errors(field, validator.errors) + return None + + +def store_custom_attributes(project, data, field, serializer): + result = [] + for custom_attribute_data in data.get(field, []): + result.append(_store_custom_attribute(project, custom_attribute_data, field, serializer)) + return result + + +## MILESTONE + +def store_milestone(project, milestone): + validator = validators.MilestoneExportValidator(data=milestone, project=project) + if validator.is_valid(): + validator.object.project = project + validator.object._importing = True + validator.save() + validator.save_watchers() + + for task_without_us in milestone.get("tasks_without_us", []): + task_without_us["user_story"] = None + store_task(project, task_without_us) + return validator + + add_errors("milestones", validator.errors) + return None + + +def store_milestones(project, data): + results = [] + for milestone_data in data.get("milestones", []): + milestone = store_milestone(project, milestone_data) + results.append(milestone) + return results + + +## USER STORIES + +def _store_role_point(project, us, role_point): + validator = validators.RolePointsExportValidator(data=role_point, context={"project": project}) + if validator.is_valid(): + try: + existing_role_point = us.role_points.get(role=validator.object.role) + existing_role_point.points = validator.object.points + existing_role_point.save() + return existing_role_point + + except RolePoints.DoesNotExist: + validator.object.user_story = us + validator.save() + return validator.object + + add_errors("role_points", validator.errors) + return None + + +def store_user_story(project, data): + if "status" not in data and project.default_us_status: + data["status"] = project.default_us_status.name + + us_data = {key: value for key, value in data.items() if key not in + ["role_points", "custom_attributes_values", 'generated_from_task', 'generated_from_issue']} + + validator = validators.UserStoryExportValidator(data=us_data, context={"project": project}) + + if validator.is_valid(): + validator.object.project = project + if validator.object.owner is None: + validator.object.owner = validator.object.project.owner + validator.object._importing = True + validator.object._not_notify = True + + validator.save() + validator.save_watchers() + + if validator.object.ref: + sequence_name = refs.make_sequence_name(project) + if not seq.exists(sequence_name): + seq.create(sequence_name) + seq.set_max(sequence_name, validator.object.ref) + else: + validator.object.ref, _ = refs.make_reference(validator.object, project) + validator.object.save() + + for us_attachment in data.get("attachments", []): + _store_attachment(project, validator.object, us_attachment) + + for role_point in data.get("role_points", []): + _store_role_point(project, validator.object, role_point) + + history_entries = data.get("history", []) + statuses = {s.name: s.id for s in project.us_statuses.all()} + for history in history_entries: + _store_history(project, validator.object, history, statuses) + + if not history_entries: + take_snapshot(validator.object, user=validator.object.owner) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = validator.object.project.userstorycustomattributes.all().values('id', 'name') + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + + _store_custom_attributes_values(validator.object, custom_attributes_values, + "user_story", + validators.UserStoryCustomAttributesValuesExportValidator) + + return validator + + add_errors("user_stories", validator.errors) + return None + + +def store_user_stories(project, data): + user_stories = {} + for userstory in data.get("user_stories", []): + validator = store_user_story(project, userstory) + if validator: + user_stories[validator.object.ref] = validator.object + return user_stories + + +def store_user_stories_related_entities(imported_user_stories, + imported_tasks, + imported_issues, + data): + for us_data in data.get("user_stories", []): + us = imported_user_stories.get(us_data.get('ref')) + if not us or \ + not (us_data.get('generated_from_task') + or us_data.get('generated_from_issue')): + continue + + if us_data.get('generated_from_task'): + generated_from_task_ref = int(us_data.get('generated_from_task')) + us.generated_from_task = imported_tasks.get(generated_from_task_ref) + + if us_data.get('generated_from_issue'): + generated_from_issue_ref = int(us_data.get('generated_from_issue')) + us.generated_from_issue = imported_issues.get(generated_from_issue_ref) + + us.save() + + +## EPICS + +def _store_epic_related_user_story(project, epic, related_user_story): + validator = validators.EpicRelatedUserStoryExportValidator(data=related_user_story, + context={"project": project}) + if validator.is_valid(): + validator.object.epic = epic + validator.object.save() + return validator.object + + add_errors("epic_related_user_stories", validator.errors) + return None + + +def store_epic(project, data): + if "status" not in data and project.default_epic_status: + data["status"] = project.default_epic_status.name + + # Ignore external related user stories + data["related_user_stories"] = filter( + lambda x: x.get("source_project_slug", None) is None, + data.get("related_user_stories", []) + ) + + validator = validators.EpicExportValidator(data=data, context={"project": project}) + + if validator.is_valid(): + validator.object.project = project + if validator.object.owner is None: + validator.object.owner = validator.object.project.owner + validator.object._importing = True + validator.object._not_notify = True + + validator.save() + validator.save_watchers() + + if validator.object.ref: + sequence_name = refs.make_sequence_name(project) + if not seq.exists(sequence_name): + seq.create(sequence_name) + seq.set_max(sequence_name, validator.object.ref) + else: + validator.object.ref, _ = refs.make_reference(validator.object, project) + validator.object.save() + + for epic_attachment in data.get("attachments", []): + _store_attachment(project, validator.object, epic_attachment) + + for related_user_story in data.get("related_user_stories", []): + _store_epic_related_user_story(project, validator.object, related_user_story) + + history_entries = data.get("history", []) + statuses = {s.name: s.id for s in project.epic_statuses.all()} + for history in history_entries: + _store_history(project, validator.object, history, statuses) + + if not history_entries: + take_snapshot(validator.object, user=validator.object.owner) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = validator.object.project.epiccustomattributes.all().values('id', 'name') + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + _store_custom_attributes_values(validator.object, custom_attributes_values, + "epic", + validators.EpicCustomAttributesValuesExportValidator) + + return validator + + add_errors("epics", validator.errors) + return None + + +def store_epics(project, data): + results = [] + for epic in data.get("epics", []): + epic = store_epic(project, epic) + results.append(epic) + return results + + +## TASKS + +def store_task(project, data): + if "status" not in data and project.default_task_status: + data["status"] = project.default_task_status.name + + validator = validators.TaskExportValidator(data=data, context={"project": project}) + if validator.is_valid(): + validator.object.project = project + if validator.object.owner is None: + validator.object.owner = validator.object.project.owner + validator.object._importing = True + validator.object._not_notify = True + + validator.save() + validator.save_watchers() + + if validator.object.ref: + sequence_name = refs.make_sequence_name(project) + if not seq.exists(sequence_name): + seq.create(sequence_name) + seq.set_max(sequence_name, validator.object.ref) + else: + validator.object.ref, _ = refs.make_reference(validator.object, project) + validator.object.save() + + for task_attachment in data.get("attachments", []): + _store_attachment(project, validator.object, task_attachment) + + history_entries = data.get("history", []) + statuses = {s.name: s.id for s in project.task_statuses.all()} + for history in history_entries: + _store_history(project, validator.object, history, statuses) + + if not history_entries: + take_snapshot(validator.object, user=validator.object.owner) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = validator.object.project.taskcustomattributes.all().values('id', 'name') + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + + _store_custom_attributes_values(validator.object, custom_attributes_values, + "task", + validators.TaskCustomAttributesValuesExportValidator) + + return validator + + add_errors("tasks", validator.errors) + return None + + +def store_tasks(project, data): + tasks = {} + for task in data.get("tasks", []): + validator = store_task(project, task) + if validator: + tasks[validator.object.ref] = validator.object + return tasks + + +## ISSUES + +def store_issue(project, data): + validator = validators.IssueExportValidator(data=data, context={"project": project}) + + if "type" not in data and project.default_issue_type: + data["type"] = project.default_issue_type.name + + if "status" not in data and project.default_issue_status: + data["status"] = project.default_issue_status.name + + if "priority" not in data and project.default_priority: + data["priority"] = project.default_priority.name + + if "severity" not in data and project.default_severity: + data["severity"] = project.default_severity.name + + if validator.is_valid(): + validator.object.project = project + if validator.object.owner is None: + validator.object.owner = validator.object.project.owner + validator.object._importing = True + validator.object._not_notify = True + + validator.save() + validator.save_watchers() + + if validator.object.ref: + sequence_name = refs.make_sequence_name(project) + if not seq.exists(sequence_name): + seq.create(sequence_name) + seq.set_max(sequence_name, validator.object.ref) + else: + validator.object.ref, _ = refs.make_reference(validator.object, project) + validator.object.save() + + for attachment in data.get("attachments", []): + _store_attachment(project, validator.object, attachment) + + history_entries = data.get("history", []) + statuses = {s.name: s.id for s in project.issue_statuses.all()} + for history in history_entries: + _store_history(project, validator.object, history, statuses) + + if not history_entries: + take_snapshot(validator.object, user=validator.object.owner) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = validator.object.project.issuecustomattributes.all().values('id', 'name') + custom_attributes_values = \ + _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + _store_custom_attributes_values(validator.object, custom_attributes_values, + "issue", + validators.IssueCustomAttributesValuesExportValidator) + + return validator + + add_errors("issues", validator.errors) + return None + + +def store_issues(project, data): + issues = {} + for issue in data.get("issues", []): + validator = store_issue(project, issue) + if validator: + issues[validator.object.ref] = validator.object + return issues + + +## WIKI PAGES + +def store_wiki_page(project, wiki_page): + wiki_page["slug"] = slugify(unidecode(wiki_page.get("slug", ""))) + validator = validators.WikiPageExportValidator(data=wiki_page) + if validator.is_valid(): + validator.object.project = project + if validator.object.owner is None: + validator.object.owner = validator.object.project.owner + validator.object._importing = True + validator.object._not_notify = True + validator.save() + validator.save_watchers() + + for attachment in wiki_page.get("attachments", []): + _store_attachment(project, validator.object, attachment) + + history_entries = wiki_page.get("history", []) + for history in history_entries: + _store_history(project, validator.object, history) + + if not history_entries: + take_snapshot(validator.object, user=validator.object.owner) + + return validator + + add_errors("wiki_pages", validator.errors) + return None + + +def store_wiki_pages(project, data): + results = [] + for wiki_page in data.get("wiki_pages", []): + results.append(store_wiki_page(project, wiki_page)) + return results + + +## WIKI LINKS + +def store_wiki_link(project, wiki_link): + validator = validators.WikiLinkExportValidator(data=wiki_link) + if validator.is_valid(): + validator.object.project = project + validator.object._importing = True + validator.save() + return validator + + add_errors("wiki_links", validator.errors) + return None + + +def store_wiki_links(project, data): + results = [] + for wiki_link in data.get("wiki_links", []): + results.append(store_wiki_link(project, wiki_link)) + return results + + +## TAGS COLORS + +def store_tags_colors(project, data): + project.tags_colors = data.get("tags_colors", []) + project.save() + return None + + +## TIMELINE + +def _store_timeline_entry(project, timeline): + validator = validators.TimelineExportValidator(data=timeline, context={"project": project}) + if validator.is_valid(): + validator.object.project = project + validator.object.namespace = build_project_namespace(project) + validator.object.object_id = project.id + validator.object.content_type = ContentType.objects.get_for_model(project.__class__) + validator.object._importing = True + validator.save() + return validator + add_errors("timeline", validator.errors) + return validator + + +def store_timeline_entries(project, data): + # Exclude epic.related_userstories entries if they are not from this project + timeline_items = filter( + lambda t: not( + (t.get("event_type", None) in ["epics.relateduserstory.create", "epics.relateduserstory.delete"]) and + (t.get("data", {}).get("userstory", {}).get("project", {}).get("slug", None) != project.slug) + ), data.get("timeline", [])) + + results = [] + for timeline in timeline_items: + tl = _store_timeline_entry(project, timeline) + results.append(tl) + return results + + +############################################# +## Store project dict +############################################# + + +def _validate_if_owner_have_enough_space_to_this_project(owner, data): + # Validate if the owner can have this project + data["owner"] = owner.email + + is_private = data.get("is_private", False) + memberships = [m["email"] for m in data.get("memberships", []) if m.get("email", None)] + enough_slots, error_message, _ = services.has_available_slot_for_new_project( + owner, + is_private, + memberships + ) + if not enough_slots: + raise err.TaigaImportError(error_message, None) + + +def _create_project_object(data): + # Create the project + project_validator = store_project(data) + + if not project_validator: + errors = get_errors(clear=True) + raise err.TaigaImportError(_("error importing project data"), None, errors=errors) + + return project_validator.object if project_validator else None + + +def _create_membership_for_project_owner(project): + owner_membership = project.memberships.filter(user=project.owner).first() + if owner_membership is None: + if project.roles.all().count() > 0: + Membership.objects.create( + project=project, + email=project.owner.email, + user=project.owner, + role=project.roles.all().first(), + is_admin=True + ) + elif not owner_membership.is_admin: + owner_membership.is_admin = True + owner_membership.save() + + +def _populate_project_object(project, data): + def check_if_there_is_some_error(message=_("error importing project data"), project=None): + errors = get_errors(clear=True) + if errors: + raise err.TaigaImportError(message, project, errors=errors) + + # Create roles + store_roles(project, data) + check_if_there_is_some_error(_("error importing roles"), None) + + # Create memberships + store_memberships(project, data) + _create_membership_for_project_owner(project) + check_if_there_is_some_error(_("error importing memberships"), project) + + # Create project attributes values + store_project_attributes_values(project, data, "epic_statuses", validators.EpicStatusExportValidator) + store_project_attributes_values(project, data, "us_statuses", validators.UserStoryStatusExportValidator) + store_project_attributes_values(project, data, "points", validators.PointsExportValidator) + store_project_attributes_values(project, data, "task_statuses", validators.TaskStatusExportValidator) + store_project_attributes_values(project, data, "issue_types", validators.IssueTypeExportValidator) + store_project_attributes_values(project, data, "issue_statuses", validators.IssueStatusExportValidator) + store_project_attributes_values(project, data, "priorities", validators.PriorityExportValidator) + store_project_attributes_values(project, data, "severities", validators.SeverityExportValidator) + store_project_attributes_values(project, data, "us_duedates", validators.UserStoryDueDateExportValidator) + store_project_attributes_values(project, data, "task_duedates", validators.TaskDueDateExportValidator) + store_project_attributes_values(project, data, "issue_duedates", validators.IssueDueDateExportValidator) + store_swimlanes(project, data) + check_if_there_is_some_error(_("error importing lists of project attributes"), project) + + # Create default values for project attributes + store_default_project_attributes_values(project, data) + check_if_there_is_some_error(_("error importing default project attribute values"), project) + + # Create custom attributes + store_custom_attributes(project, data, "epiccustomattributes", + validators.EpicCustomAttributeExportValidator) + store_custom_attributes(project, data, "userstorycustomattributes", + validators.UserStoryCustomAttributeExportValidator) + store_custom_attributes(project, data, "taskcustomattributes", + validators.TaskCustomAttributeExportValidator) + store_custom_attributes(project, data, "issuecustomattributes", + validators.IssueCustomAttributeExportValidator) + check_if_there_is_some_error(_("error importing custom attributes"), project) + # Create milestones + store_milestones(project, data) + check_if_there_is_some_error(_("error importing sprints"), project) + + # Create issues + imported_issues = store_issues(project, data) + check_if_there_is_some_error(_("error importing issues"), project) + + # Create user stories + imported_user_stories = store_user_stories(project, data) + check_if_there_is_some_error(_("error importing user stories"), project) + + # Create epics + store_epics(project, data) + check_if_there_is_some_error(_("error importing epics"), project) + + # Create tasks + imported_tasks = store_tasks(project, data) + check_if_there_is_some_error(_("error importing tasks"), project) + + # Create user stories relationships + store_user_stories_related_entities(imported_user_stories, imported_tasks, imported_issues, data) + + # Create wiki pages + store_wiki_pages(project, data) + check_if_there_is_some_error(_("error importing wiki pages"), project) + + # Create wiki links + store_wiki_links(project, data) + check_if_there_is_some_error(_("error importing wiki links"), project) + + # Create tags + store_tags_colors(project, data) + check_if_there_is_some_error(_("error importing tags"), project) + + # Create timeline + store_timeline_entries(project, data) + check_if_there_is_some_error(_("error importing timelines"), project) + + # Regenerate stats + project.refresh_totals() + + +def store_project_from_dict(data, owner=None): + # Validate + if owner: + _validate_if_owner_have_enough_space_to_this_project(owner, data) + + # Create project + project = _create_project_object(data) + + # Populate project + try: + _populate_project_object(project, data) + except err.TaigaImportError: + # raise known import errors + raise + except Exception as e: + logger.exception('Unexpected error importing project (by %s)', owner or "unknown user") + # raise unknown errors as import error + raise err.TaigaImportError(_("unexpected error importing project"), project) + + return project diff --git a/taiga/export_import/services/validations.py b/taiga/export_import/services/validations.py new file mode 100644 index 000000000..e19681cb7 --- /dev/null +++ b/taiga/export_import/services/validations.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.utils.translation import gettext as _ + + +def has_available_slot_for_new_project(owner, is_private, member_emails): + """Return if user has enought slots to create a new projects. + + :param owner: The new owner. + :param is_private: 'True' if new project will be private. + :param member_emails: A list of user ids for new members. + + :return: (bool, error_mesage, int) return a tuple (can be duplicated, error message, total new project members). + """ + current_projects = owner.owned_projects.filter(is_private=is_private).count() + Membership = apps.get_model("projects", "Membership") + actual_emails_members = (Membership.objects.filter(project__is_private=is_private, + project__owner_id=owner.id, + user_id__isnull=False) + .order_by("user__email") + .distinct("user__email") + .values_list("user__email", flat=True)) + + total_memberships = len(set(list(actual_emails_members) + member_emails) - set([owner.email])) + 1 + + if is_private: + max_projects = owner.max_private_projects + max_memberships = owner.max_memberships_private_projects + error_project_exceeded = _("You can't have more private projects") + error_memberships_exceeded = _("This project reaches your current limit of memberships for private projects") + else: + max_projects = owner.max_public_projects + max_memberships = owner.max_memberships_public_projects + error_project_exceeded = _("You can't have more public projects") + error_memberships_exceeded = _("This project reaches your current limit of memberships for public projects") + + if max_projects is not None and current_projects >= max_projects: + return (False, error_project_exceeded, total_memberships) + + if max_memberships is not None and total_memberships > max_memberships: + return (False, error_memberships_exceeded, total_memberships) + + return (True, None, total_memberships) diff --git a/taiga/export_import/tasks.py b/taiga/export_import/tasks.py new file mode 100644 index 000000000..18f459e07 --- /dev/null +++ b/taiga/export_import/tasks.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime +import logging +import sys +import gzip + +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile +from django.utils import timezone + +from django.conf import settings +from django.utils.translation import gettext as _ + +from taiga.base.mails import mail_builder +from taiga.base.utils import json +from taiga.celery import app + +from . import exceptions as err +from . import services +from .renderers import ExportRenderer + +logger = logging.getLogger('taiga.export_import') + + +@app.task(bind=True) +def dump_project(self, user, project, dump_format): + try: + if dump_format == "gzip": + path = "exports/{}/{}-{}.json.gz".format(project.pk, project.slug, self.request.id) + with default_storage.open(path, mode="wb") as outfile: + services.render_project(project, gzip.GzipFile(fileobj=outfile, mode="wb")) + else: + path = "exports/{}/{}-{}.json".format(project.pk, project.slug, self.request.id) + with default_storage.open(path, mode="wb") as outfile: + services.render_project(project, outfile) + + url = default_storage.url(path) + + except Exception: + # Error + ctx = { + "user": user, + "error_subject": _("Error generating project dump"), + "error_message": _("Error generating project dump"), + "project": project + } + email = mail_builder.export_error(user, ctx) + email.send() + logger.error('Error generating dump %s (by %s)', project.slug, user, exc_info=sys.exc_info()) + else: + # Success + deletion_date = timezone.now() + datetime.timedelta(seconds=settings.EXPORTS_TTL) + ctx = { + "url": url, + "project": project, + "user": user, + "deletion_date": deletion_date + } + email = mail_builder.dump_project(user, ctx) + email.send() + + +@app.task +def delete_project_dump(project_id, project_slug, task_id, dump_format): + if dump_format == "gzip": + path = "exports/{}/{}-{}.json.gz".format(project_id, project_slug, task_id) + else: + path = "exports/{}/{}-{}.json".format(project_id, project_slug, task_id) + default_storage.delete(path) + + +ADMIN_ERROR_LOAD_PROJECT_DUMP_MESSAGE = _(""" + +Error loading dump by {user_full_name} <{user_email}>:" + + +REASON: +------- +{reason} + +DETAILS: +-------- +{details} + +TRACE ERROR: +------------""") + + +@app.task +def load_project_dump(user, dump): + try: + project = services.store_project_from_dict(dump, user) + except err.TaigaImportError as e: + # On Error + ## remove project + if e.project: + e.project.delete_related_content() + e.project.delete() + + ## send email to the user + error_subject = _("Error loading project dump") + error_message = e.message or _("Error loading your project dump file") + + ctx = { + "user": user, + "error_subject": error_message, + "error_message": error_subject, + "details": json.dumps(e.errors, indent=4) + } + email = mail_builder.import_error(user, ctx) + email.send() + + ## logged the error to sysadmins + text = ADMIN_ERROR_LOAD_PROJECT_DUMP_MESSAGE.format( + user_full_name=user, + user_email=user.email, + reason=e.message or _(" -- no detail info --"), + details=json.dumps(e.errors, indent=4) + ) + logger.error(text, exc_info=sys.exc_info()) + + else: + # On Success + ctx = {"user": user, "project": project} + email = mail_builder.load_dump(user, ctx) + email.send() diff --git a/taiga/export_import/templates/emails/dump_project-body-html.jinja b/taiga/export_import/templates/emails/dump_project-body-html.jinja new file mode 100644 index 000000000..9e901fb10 --- /dev/null +++ b/taiga/export_import/templates/emails/dump_project-body-html.jinja @@ -0,0 +1,21 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), project=project.name, url=url, deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %} +

Project dump generated

+

Hello {{ user }},

+

Your dump from project {{ project }} has been correctly generated.

+

You can download it here:

+ Download the dump file +

This file will be deleted on {{ deletion_date }}.

+

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/export_import/templates/emails/dump_project-body-text.jinja b/taiga/export_import/templates/emails/dump_project-body-text.jinja new file mode 100644 index 000000000..d5e07ef5d --- /dev/null +++ b/taiga/export_import/templates/emails/dump_project-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), project=project.name, url=url, deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %} +Hello {{ user }}, + +Your dump from project {{ project }} has been correctly generated. You can download it here: + +{{ url }} + +This file will be deleted on {{ deletion_date }}. + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/export_import/templates/emails/dump_project-subject.jinja b/taiga/export_import/templates/emails/dump_project-subject.jinja new file mode 100644 index 000000000..5ee36cae4 --- /dev/null +++ b/taiga/export_import/templates/emails/dump_project-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %}[{{ project }}] Your project dump has been generated{% endtrans %} diff --git a/taiga/export_import/templates/emails/export_error-body-html.jinja b/taiga/export_import/templates/emails/export_error-body-html.jinja new file mode 100644 index 000000000..6a07ad58f --- /dev/null +++ b/taiga/export_import/templates/emails/export_error-body-html.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), error_message=error_message, support_email=sr("support.email"), project=project.name %} +

{{ error_message }}

+

Hello {{ user }},

+

Your project {{ project }} has not been exported correctly.

+

The Taiga system administrators have been informed.
Please, try it again or contact with the support team at + {{ support_email }}

+

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/export_import/templates/emails/export_error-body-text.jinja b/taiga/export_import/templates/emails/export_error-body-text.jinja new file mode 100644 index 000000000..2f8aac432 --- /dev/null +++ b/taiga/export_import/templates/emails/export_error-body-text.jinja @@ -0,0 +1,21 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), error_message=error_message, support_email=sr("support.email"), project=project.name %} +Hello {{ user }}, + +{{ error_message }} +Your project {{ project }} has not been exported correctly. + +The Taiga system administrators have been informed. + +Please, try it again or contact with the support team at {{ support_email }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/export_import/templates/emails/export_error-subject.jinja b/taiga/export_import/templates/emails/export_error-subject.jinja new file mode 100644 index 000000000..a450b46d0 --- /dev/null +++ b/taiga/export_import/templates/emails/export_error-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans error_subject=error_subject|safe, project=project.name|safe %}[{{ project }}] {{ error_subject }}{% endtrans %} diff --git a/taiga/export_import/templates/emails/import_error-body-html.jinja b/taiga/export_import/templates/emails/import_error-body-html.jinja new file mode 100644 index 000000000..fa3526339 --- /dev/null +++ b/taiga/export_import/templates/emails/import_error-body-html.jinja @@ -0,0 +1,23 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), error_message=error_message, support_email=sr("support.email"), product_name=sr("product_name") %} +

{{ error_message }}

+

Hello {{ user }},

+

Your project has not been imported correctly.

+

The {{ product_name }} system administrators have been informed.
Please, try it again or contact with the support team at + {{ support_email }}

+

{{ signature }}

+

Error details

+
{{ details }}
+ {% endtrans %} + +{% endblock %} diff --git a/taiga/export_import/templates/emails/import_error-body-text.jinja b/taiga/export_import/templates/emails/import_error-body-text.jinja new file mode 100644 index 000000000..7baa1c294 --- /dev/null +++ b/taiga/export_import/templates/emails/import_error-body-text.jinja @@ -0,0 +1,22 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), error_message=error_message, support_email=sr("support.email"), product_name=sr("product_name") %} +Hello {{ user }}, + +{{ error_message }} + +Your project has not been imported correctly. + +The {{ product_name }} system administrators have been informed. + +Please, try it again or contact with the support team at {{ support_email }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/export_import/templates/emails/import_error-subject.jinja b/taiga/export_import/templates/emails/import_error-subject.jinja new file mode 100644 index 000000000..0194ee217 --- /dev/null +++ b/taiga/export_import/templates/emails/import_error-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans error_subject=error_subject|safe %}[Taiga] {{ error_subject }}{% endtrans %} diff --git a/taiga/export_import/templates/emails/load_dump-body-html.jinja b/taiga/export_import/templates/emails/load_dump-body-html.jinja new file mode 100644 index 000000000..eb6ea9300 --- /dev/null +++ b/taiga/export_import/templates/emails/load_dump-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +

Project dump imported

+

Hello {{ user }},

+

Your project dump has been correctly imported.

+ Go to {{ project }} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/export_import/templates/emails/load_dump-body-text.jinja b/taiga/export_import/templates/emails/load_dump-body-text.jinja new file mode 100644 index 000000000..b3c57ba56 --- /dev/null +++ b/taiga/export_import/templates/emails/load_dump-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +Hello {{ user }}, + +Your project dump has been correctly imported. + +You can see the project {{ project }} here: + +{{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/export_import/templates/emails/load_dump-subject.jinja b/taiga/export_import/templates/emails/load_dump-subject.jinja new file mode 100644 index 000000000..bc4d16669 --- /dev/null +++ b/taiga/export_import/templates/emails/load_dump-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %}[{{ project }}] Your project dump has been imported{% endtrans %} diff --git a/taiga/export_import/throttling.py b/taiga/export_import/throttling.py new file mode 100644 index 000000000..4a5bc9913 --- /dev/null +++ b/taiga/export_import/throttling.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import throttling + + +class ImportModeRateThrottle(throttling.UserRateThrottle): + scope = "import-mode" + + +class ImportDumpModeRateThrottle(throttling.UserRateThrottle): + scope = "import-dump-mode" diff --git a/taiga/export_import/validators/__init__.py b/taiga/export_import/validators/__init__.py new file mode 100644 index 000000000..dbc09340c --- /dev/null +++ b/taiga/export_import/validators/__init__.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from .validators import PointsExportValidator +from .validators import EpicStatusExportValidator +from .validators import UserStoryStatusExportValidator +from .validators import UserStoryDueDateExportValidator +from .validators import TaskStatusExportValidator +from .validators import TaskDueDateExportValidator +from .validators import IssueStatusExportValidator +from .validators import IssueDueDateExportValidator +from .validators import PriorityExportValidator +from .validators import SeverityExportValidator +from .validators import SwimlaneUserStoryStatusExportValidator +from .validators import SwimlaneExportValidator +from .validators import IssueTypeExportValidator +from .validators import RoleExportValidator +from .validators import EpicCustomAttributeExportValidator +from .validators import UserStoryCustomAttributeExportValidator +from .validators import TaskCustomAttributeExportValidator +from .validators import IssueCustomAttributeExportValidator +from .validators import BaseCustomAttributesValuesExportValidator +from .validators import EpicCustomAttributesValuesExportValidator +from .validators import UserStoryCustomAttributesValuesExportValidator +from .validators import TaskCustomAttributesValuesExportValidator +from .validators import IssueCustomAttributesValuesExportValidator +from .validators import MembershipExportValidator +from .validators import RolePointsExportValidator +from .validators import MilestoneExportValidator +from .validators import TaskExportValidator +from .validators import EpicRelatedUserStoryExportValidator +from .validators import EpicExportValidator +from .validators import UserStoryExportValidator +from .validators import IssueExportValidator +from .validators import WikiPageExportValidator +from .validators import WikiLinkExportValidator +from .validators import TimelineExportValidator +from .validators import ProjectExportValidator +from .mixins import AttachmentExportValidator +from .mixins import HistoryExportValidator diff --git a/taiga/export_import/validators/cache.py b/taiga/export_import/validators/cache.py new file mode 100644 index 000000000..0e7d81631 --- /dev/null +++ b/taiga/export_import/validators/cache.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.users import models as users_models + +_cache_user_by_pk = {} +_cache_user_by_email = {} +_custom_tasks_attributes_cache = {} +_custom_issues_attributes_cache = {} +_custom_epics_attributes_cache = {} +_custom_userstories_attributes_cache = {} + + +def cached_get_user_by_pk(pk): + if pk not in _cache_user_by_pk: + try: + _cache_user_by_pk[pk] = users_models.User.objects.get(pk=pk) + except Exception: + _cache_user_by_pk[pk] = users_models.User.objects.get(pk=pk) + return _cache_user_by_pk[pk] + +def cached_get_user_by_email(email): + if email not in _cache_user_by_email: + try: + _cache_user_by_email[email] = users_models.User.objects.get(email=email) + except Exception: + _cache_user_by_email[email] = users_models.User.objects.get(email=email) + return _cache_user_by_email[email] diff --git a/taiga/export_import/validators/fields.py b/taiga/export_import/validators/fields.py new file mode 100644 index 000000000..5785fb7f7 --- /dev/null +++ b/taiga/export_import/validators/fields.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import base64 +import copy + +from django.core.files.base import ContentFile +from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import gettext as _ +from django.contrib.contenttypes.models import ContentType + +from taiga.base.api import serializers +from taiga.base.exceptions import ValidationError +from taiga.base.fields import JSONField +from taiga.mdrender.service import render as mdrender +from taiga.users import models as users_models + +from .cache import cached_get_user_by_email + + +class FileField(serializers.WritableField): + read_only = False + + def from_native(self, data): + if not data: + return None + + decoded_data = b'' + # The original file was encoded by chunks but we don't really know its + # length or if it was multiple of 3 so we must iterate over all those chunks + # decoding them one by one + for decoding_chunk in data['data'].split("="): + # When encoding to base64 3 bytes are transformed into 4 bytes and + # the extra space of the block is filled with = + # We must ensure that the decoding chunk has a length multiple of 4 so + # we restore the stripped '='s adding appending them until the chunk has + # a length multiple of 4 + decoding_chunk += "=" * (-len(decoding_chunk) % 4) + decoded_data += base64.b64decode(decoding_chunk + "=") + + return ContentFile(decoded_data, name=data['name']) + + +class ContentTypeField(serializers.RelatedField): + read_only = False + + def from_native(self, data): + try: + return ContentType.objects.get_by_natural_key(*data) + except Exception: + return None + + +class RelatedNoneSafeField(serializers.RelatedField): + def field_from_native(self, data, files, field_name, into): + if self.read_only: + return + + try: + if self.many: + try: + # Form data + value = data.getlist(field_name) + if value == [''] or value == []: + raise KeyError + except AttributeError: + # Non-form data + value = data[field_name] + else: + value = data[field_name] + except KeyError: + if self.partial: + return + value = self.get_default_value() + + key = self.source or field_name + if value in self.null_values: + if self.required: + raise ValidationError(self.error_messages['required']) + into[key] = None + elif self.many: + into[key] = [self.from_native(item) for item in value if self.from_native(item) is not None] + else: + into[key] = self.from_native(value) + + +class UserRelatedField(RelatedNoneSafeField): + read_only = False + + def from_native(self, data): + try: + return cached_get_user_by_email(data) + except users_models.User.DoesNotExist: + return None + + +class UserPkField(serializers.RelatedField): + read_only = False + + def from_native(self, data): + try: + user = cached_get_user_by_email(data) + return user.pk + except Exception: + return None + + +class CommentField(serializers.WritableField): + read_only = False + + def field_from_native(self, data, files, field_name, into): + super().field_from_native(data, files, field_name, into) + into["comment_html"] = mdrender(self.context['project'], data.get("comment", "")) + + +class ProjectRelatedField(serializers.RelatedField): + read_only = False + null_values = (None, "") + + def __init__(self, slug_field, *args, **kwargs): + self.slug_field = slug_field + super().__init__(*args, **kwargs) + + def from_native(self, data): + try: + kwargs = {self.slug_field: data, "project": self.context['project']} + return self.queryset.get(**kwargs) + except ObjectDoesNotExist: + raise ValidationError(_("{}=\"{}\" not found in this project".format(self.slug_field, data))) + + +class HistorySnapshotField(JSONField): + def from_native(self, data): + if data is None: + return {} + + owner = UserRelatedField().from_native(data.get("owner")) + if owner: + data["owner"] = owner.pk + + assigned_to = UserRelatedField().from_native(data.get("assigned_to")) + if assigned_to: + data["assigned_to"] = assigned_to.pk + + return data + + +class HistoryUserField(JSONField): + def from_native(self, data): + if data is None: + return {} + + if len(data) < 2: + return {} + + user = UserRelatedField().from_native(data[0]) + + if user: + pk = user.pk + else: + pk = None + + return {"pk": pk, "name": data[1]} + + +class HistoryValuesField(JSONField): + def from_native(self, data): + if data is None: + return [] + if "users" in data: + data['users'] = list(map(UserPkField().from_native, data['users'])) + return data + + +class HistoryDiffField(JSONField): + def from_native(self, data): + if data is None: + return [] + + if "assigned_to" in data: + data['assigned_to'] = list(map(UserPkField().from_native, data['assigned_to'])) + return data + + +class TimelineDataField(serializers.WritableField): + read_only = False + + def from_native(self, data): + new_data = copy.deepcopy(data) + try: + user = cached_get_user_by_email(new_data["user"]["email"]) + new_data["user"]["id"] = user.id + del new_data["user"]["email"] + except users_models.User.DoesNotExist: + pass + + return new_data diff --git a/taiga/export_import/validators/mixins.py b/taiga/export_import/validators/mixins.py new file mode 100644 index 000000000..d2fb6f399 --- /dev/null +++ b/taiga/export_import/validators/mixins.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.contenttypes.models import ContentType + +from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.projects.history import models as history_models +from taiga.projects.attachments import models as attachments_models +from taiga.projects.notifications import services as notifications_services +from taiga.projects.history import services as history_service + +from .fields import (UserRelatedField, HistoryUserField, HistoryDiffField, + JSONField, HistorySnapshotField, + HistoryValuesField, CommentField, FileField) + + +class HistoryExportValidator(validators.ModelValidator): + user = HistoryUserField() + diff = HistoryDiffField(required=False) + snapshot = HistorySnapshotField(required=False) + values = HistoryValuesField(required=False) + comment = CommentField(required=False) + delete_comment_date = serializers.DateTimeField(required=False) + delete_comment_user = HistoryUserField(required=False) + + class Meta: + model = history_models.HistoryEntry + exclude = ("id", "comment_html", "key", "project") + + def restore_object(self, attrs, instance=None): + snapshot = attrs["snapshot"] + statuses = self.context.get("statuses", {}) + if "status" in snapshot: + status_id = statuses.get(snapshot["status"], None) + attrs["snapshot"]["status"] = status_id + + instance = super(HistoryExportValidator, self).restore_object(attrs, instance) + return instance + +class AttachmentExportValidator(validators.ModelValidator): + owner = UserRelatedField(required=False) + attached_file = FileField() + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = attachments_models.Attachment + exclude = ('id', 'content_type', 'object_id', 'project') + + +class WatcheableObjectModelValidatorMixin(validators.ModelValidator): + watchers = UserRelatedField(many=True, required=False) + + def __init__(self, *args, **kwargs): + self._watchers_field = self.base_fields.pop("watchers", None) + super(WatcheableObjectModelValidatorMixin, self).__init__(*args, **kwargs) + + """ + watchers is not a field from the model so we need to do some magic to make it work like a normal field + It's supposed to be represented as an email list but internally it's treated like notifications.Watched instances + """ + + def restore_object(self, attrs, instance=None): + self.fields.pop("watchers", None) + instance = super(WatcheableObjectModelValidatorMixin, self).restore_object(attrs, instance) + self._watchers = self.init_data.get("watchers", []) + return instance + + def save_watchers(self): + new_watcher_emails = set(self._watchers) + old_watcher_emails = set(self.object.get_watchers().values_list("email", flat=True)) + adding_watcher_emails = list(new_watcher_emails.difference(old_watcher_emails)) + removing_watcher_emails = list(old_watcher_emails.difference(new_watcher_emails)) + + User = get_user_model() + adding_users = User.objects.filter(email__in=adding_watcher_emails) + removing_users = User.objects.filter(email__in=removing_watcher_emails) + + for user in adding_users: + notifications_services.add_watcher(self.object, user) + + for user in removing_users: + notifications_services.remove_watcher(self.object, user) + + self.object.watchers = [user.email for user in self.object.get_watchers()] + + def to_native(self, obj): + ret = super(WatcheableObjectModelValidatorMixin, self).to_native(obj) + ret["watchers"] = [user.email for user in obj.get_watchers()] + return ret diff --git a/taiga/export_import/validators/validators.py b/taiga/export_import/validators/validators.py new file mode 100644 index 000000000..9e8f07ecb --- /dev/null +++ b/taiga/export_import/validators/validators.py @@ -0,0 +1,445 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.fields import JSONField, PgArrayField +from taiga.base.exceptions import ValidationError + +from taiga.projects import models as projects_models +from taiga.projects.custom_attributes import models as custom_attributes_models +from taiga.projects.epics import models as epics_models +from taiga.projects.userstories import models as userstories_models +from taiga.projects.tasks import models as tasks_models +from taiga.projects.issues import models as issues_models +from taiga.projects.milestones import models as milestones_models +from taiga.projects.wiki import models as wiki_models +from taiga.timeline import models as timeline_models +from taiga.users import models as users_models + +from .fields import (FileField, UserRelatedField, + ProjectRelatedField, + TimelineDataField, ContentTypeField) +from .mixins import WatcheableObjectModelValidatorMixin +from .cache import (_custom_tasks_attributes_cache, + _custom_epics_attributes_cache, + _custom_userstories_attributes_cache, + _custom_issues_attributes_cache) + + +class PointsExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.Points + exclude = ('id', 'project') + + +class EpicStatusExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.EpicStatus + exclude = ('id', 'project') + + +class UserStoryStatusExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.UserStoryStatus + exclude = ('id', 'project') + + +class UserStoryDueDateExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.UserStoryDueDate + exclude = ('id', 'project') + + +class TaskStatusExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.TaskStatus + exclude = ('id', 'project') + + +class TaskDueDateExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.TaskDueDate + exclude = ('id', 'project') + + +class IssueStatusExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.IssueStatus + exclude = ('id', 'project') + + +class IssueDueDateExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.IssueDueDate + exclude = ('id', 'project') + + +class PriorityExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.Priority + exclude = ('id', 'project') + + +class SeverityExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.Severity + exclude = ('id', 'project') + + +class IssueTypeExportValidator(validators.ModelValidator): + class Meta: + model = projects_models.IssueType + exclude = ('id', 'project') + + +class SwimlaneUserStoryStatusExportValidator(validators.ModelValidator): + status = ProjectRelatedField(slug_field="name") + + class Meta: + model = projects_models.SwimlaneUserStoryStatus + exclude = ('id', 'swimlane') + + +class SwimlaneExportValidator(validators.ModelValidator): + statuses = SwimlaneUserStoryStatusExportValidator(many=True, required=False) + + class Meta: + model = projects_models.Swimlane + exclude = ('id', 'project') + + +class RoleExportValidator(validators.ModelValidator): + permissions = PgArrayField(required=False) + + class Meta: + model = users_models.Role + exclude = ('id', 'project') + + +class EpicCustomAttributeExportValidator(validators.ModelValidator): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.EpicCustomAttribute + exclude = ('id', 'project') + + +class UserStoryCustomAttributeExportValidator(validators.ModelValidator): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.UserStoryCustomAttribute + exclude = ('id', 'project') + + +class TaskCustomAttributeExportValidator(validators.ModelValidator): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.TaskCustomAttribute + exclude = ('id', 'project') + + +class IssueCustomAttributeExportValidator(validators.ModelValidator): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.IssueCustomAttribute + exclude = ('id', 'project') + + +class BaseCustomAttributesValuesExportValidator(validators.ModelValidator): + attributes_values = JSONField(source="attributes_values", required=True) + _custom_attribute_model = None + _container_field = None + + class Meta: + exclude = ("id",) + + def validate_attributes_values(self, attrs, source): + # values must be a dict + data_values = attrs.get("attributes_values", None) + if self.object: + data_values = (data_values or self.object.attributes_values) + + if type(data_values) is not dict: + raise ValidationError(_("Invalid content. It must be {\"key\": \"value\",...}")) + + # Values keys must be in the container object project + data_container = attrs.get(self._container_field, None) + if data_container: + project_id = data_container.project_id + elif self.object: + project_id = getattr(self.object, self._container_field).project_id + else: + project_id = None + + values_ids = list(data_values.keys()) + qs = self._custom_attribute_model.objects.filter(project=project_id, + id__in=values_ids) + if qs.count() != len(values_ids): + raise ValidationError(_("It contains invalid custom fields.")) + + return attrs + + +class EpicCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator): + _custom_attribute_model = custom_attributes_models.EpicCustomAttribute + _container_model = "epics.Epic" + _container_field = "epic" + + class Meta(BaseCustomAttributesValuesExportValidator.Meta): + model = custom_attributes_models.EpicCustomAttributesValues + + +class UserStoryCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator): + _custom_attribute_model = custom_attributes_models.UserStoryCustomAttribute + _container_model = "userstories.UserStory" + _container_field = "user_story" + + class Meta(BaseCustomAttributesValuesExportValidator.Meta): + model = custom_attributes_models.UserStoryCustomAttributesValues + + +class TaskCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator): + _custom_attribute_model = custom_attributes_models.TaskCustomAttribute + _container_field = "task" + + class Meta(BaseCustomAttributesValuesExportValidator.Meta): + model = custom_attributes_models.TaskCustomAttributesValues + + +class IssueCustomAttributesValuesExportValidator(BaseCustomAttributesValuesExportValidator): + _custom_attribute_model = custom_attributes_models.IssueCustomAttribute + _container_field = "issue" + + class Meta(BaseCustomAttributesValuesExportValidator.Meta): + model = custom_attributes_models.IssueCustomAttributesValues + + +class MembershipExportValidator(validators.ModelValidator): + user = UserRelatedField(required=False) + role = ProjectRelatedField(slug_field="name") + invited_by = UserRelatedField(required=False) + + class Meta: + model = projects_models.Membership + exclude = ('id', 'project', 'token') + + def full_clean(self, instance): + return instance + + +class RolePointsExportValidator(validators.ModelValidator): + role = ProjectRelatedField(slug_field="name") + points = ProjectRelatedField(slug_field="name") + + class Meta: + model = userstories_models.RolePoints + exclude = ('id', 'user_story') + + +class MilestoneExportValidator(WatcheableObjectModelValidatorMixin): + owner = UserRelatedField(required=False) + modified_date = serializers.DateTimeField(required=False) + estimated_start = serializers.DateField(required=False) + estimated_finish = serializers.DateField(required=False) + + def __init__(self, *args, **kwargs): + project = kwargs.pop('project', None) + super(MilestoneExportValidator, self).__init__(*args, **kwargs) + if project: + self.project = project + + def validate_name(self, attrs, source): + """ + Check the milestone name is not duplicated in the project + """ + name = attrs[source] + qs = self.project.milestones.filter(name=name) + if qs.exists(): + raise ValidationError(_("Duplicated name")) + + return attrs + + class Meta: + model = milestones_models.Milestone + exclude = ('id', 'project') + + +class TaskExportValidator(WatcheableObjectModelValidatorMixin): + owner = UserRelatedField(required=False) + status = ProjectRelatedField(slug_field="name") + user_story = ProjectRelatedField(slug_field="ref", required=False) + milestone = ProjectRelatedField(slug_field="name", required=False) + assigned_to = UserRelatedField(required=False) + modified_date = serializers.DateTimeField(required=False) + due_date = serializers.DateTimeField(required=False) + + class Meta: + model = tasks_models.Task + exclude = ('id', 'project') + + def custom_attributes_queryset(self, project): + if project.id not in _custom_tasks_attributes_cache: + _custom_tasks_attributes_cache[project.id] = list(project.taskcustomattributes.all().values('id', 'name')) + return _custom_tasks_attributes_cache[project.id] + + +class EpicRelatedUserStoryExportValidator(validators.ModelValidator): + user_story = ProjectRelatedField(slug_field="ref") + order = serializers.IntegerField() + source_project_slug = serializers.CharField(required=False) + + def validate_source_project_slug(self, attrs, source): + if source in attrs and attrs[source] is not None and attrs[source] != "": + msg = _("An Epic has a related story from an external project (%(project)s) and cannot be imported") % {"project": attrs[source]} + raise ValidationError(msg) + + attrs.pop(source, None) + return attrs + + class Meta: + model = epics_models.RelatedUserStory + extra_kwargs = { + 'source_project_slug': {'write_only': True}, + } + exclude = ('id', 'epic') + + +class EpicExportValidator(WatcheableObjectModelValidatorMixin): + owner = UserRelatedField(required=False) + assigned_to = UserRelatedField(required=False) + status = ProjectRelatedField(slug_field="name") + modified_date = serializers.DateTimeField(required=False) + user_stories = EpicRelatedUserStoryExportValidator(many=True, required=False) + + class Meta: + model = epics_models.Epic + exclude = ('id', 'project') + + def custom_attributes_queryset(self, project): + if project.id not in _custom_epics_attributes_cache: + _custom_epics_attributes_cache[project.id] = list( + project.epiccustomattributes.all().values('id', 'name') + ) + return _custom_epics_attributes_cache[project.id] + + +class UserStoryExportValidator(WatcheableObjectModelValidatorMixin): + role_points = RolePointsExportValidator(many=True, required=False) + owner = UserRelatedField(required=False) + assigned_to = UserRelatedField(required=False) + assigned_users = UserRelatedField(many=True, required=False) + status = ProjectRelatedField(slug_field="name") + swimlane = ProjectRelatedField(slug_field="name", required=False) + milestone = ProjectRelatedField(slug_field="name", required=False) + modified_date = serializers.DateTimeField(required=False) + generated_from_issue = ProjectRelatedField(slug_field="ref", required=False) + generated_from_task = ProjectRelatedField(slug_field="ref", required=False) + due_date = serializers.DateTimeField(required=False) + + class Meta: + model = userstories_models.UserStory + exclude = ('id', 'project', 'points', 'tasks', 'from_task_ref') + + def custom_attributes_queryset(self, project): + if project.id not in _custom_userstories_attributes_cache: + _custom_userstories_attributes_cache[project.id] = list( + project.userstorycustomattributes.all().values('id', 'name') + ) + return _custom_userstories_attributes_cache[project.id] + + +class IssueExportValidator(WatcheableObjectModelValidatorMixin): + owner = UserRelatedField(required=False) + status = ProjectRelatedField(slug_field="name") + assigned_to = UserRelatedField(required=False) + priority = ProjectRelatedField(slug_field="name") + severity = ProjectRelatedField(slug_field="name") + type = ProjectRelatedField(slug_field="name") + milestone = ProjectRelatedField(slug_field="name", required=False) + modified_date = serializers.DateTimeField(required=False) + due_date = serializers.DateTimeField(required=False) + + class Meta: + model = issues_models.Issue + exclude = ('id', 'project') + + def custom_attributes_queryset(self, project): + if project.id not in _custom_issues_attributes_cache: + _custom_issues_attributes_cache[project.id] = list(project.issuecustomattributes.all().values('id', 'name')) + return _custom_issues_attributes_cache[project.id] + + +class WikiPageExportValidator(WatcheableObjectModelValidatorMixin): + owner = UserRelatedField(required=False) + last_modifier = UserRelatedField(required=False) + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = wiki_models.WikiPage + exclude = ('id', 'project') + + +class WikiLinkExportValidator(validators.ModelValidator): + class Meta: + model = wiki_models.WikiLink + exclude = ('id', 'project') + + +class TimelineExportValidator(validators.ModelValidator): + data = TimelineDataField() + data_content_type = ContentTypeField() + + class Meta: + model = timeline_models.Timeline + exclude = ('id', 'project', 'namespace', 'object_id', 'content_type') + + +class ProjectExportValidator(WatcheableObjectModelValidatorMixin): + logo = FileField(required=False) + anon_permissions = PgArrayField(required=False) + public_permissions = PgArrayField(required=False) + modified_date = serializers.DateTimeField(required=False) + roles = RoleExportValidator(many=True, required=False) + owner = UserRelatedField(required=False) + memberships = MembershipExportValidator(many=True, required=False) + points = PointsExportValidator(many=True, required=False) + us_statuses = UserStoryStatusExportValidator(many=True, required=False) + task_statuses = TaskStatusExportValidator(many=True, required=False) + issue_types = IssueTypeExportValidator(many=True, required=False) + issue_statuses = IssueStatusExportValidator(many=True, required=False) + priorities = PriorityExportValidator(many=True, required=False) + severities = SeverityExportValidator(many=True, required=False) + swimlanes = SwimlaneExportValidator(many=True, required=False) + tags_colors = JSONField(required=False) + creation_template = serializers.SlugRelatedField(slug_field="slug", required=False) + default_points = serializers.SlugRelatedField(slug_field="name", required=False) + default_us_status = serializers.SlugRelatedField(slug_field="name", required=False) + default_task_status = serializers.SlugRelatedField(slug_field="name", required=False) + default_priority = serializers.SlugRelatedField(slug_field="name", required=False) + default_severity = serializers.SlugRelatedField(slug_field="name", required=False) + default_issue_status = serializers.SlugRelatedField(slug_field="name", required=False) + default_issue_type = serializers.SlugRelatedField(slug_field="name", required=False) + default_swimlane = serializers.SlugRelatedField(slug_field="name", required=False) + userstorycustomattributes = UserStoryCustomAttributeExportValidator(many=True, required=False) + taskcustomattributes = TaskCustomAttributeExportValidator(many=True, required=False) + issuecustomattributes = IssueCustomAttributeExportValidator(many=True, required=False) + user_stories = UserStoryExportValidator(many=True, required=False) + tasks = TaskExportValidator(many=True, required=False) + milestones = MilestoneExportValidator(many=True, required=False) + issues = IssueExportValidator(many=True, required=False) + wiki_links = WikiLinkExportValidator(many=True, required=False) + wiki_pages = WikiPageExportValidator(many=True, required=False) + + class Meta: + model = projects_models.Project + exclude = ('id', 'members') diff --git a/taiga/external_apps/__init__.py b/taiga/external_apps/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/external_apps/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/external_apps/admin.py b/taiga/external_apps/admin.py new file mode 100644 index 000000000..812d03b43 --- /dev/null +++ b/taiga/external_apps/admin.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin + +from . import models + + +class ApplicationAdmin(admin.ModelAdmin): + readonly_fields=("id",) + +admin.site.register(models.Application, ApplicationAdmin) + + +class ApplicationTokenAdmin(admin.ModelAdmin): + readonly_fields=("token",) + search_fields = ("user__username", "user__full_name", "user__email", "application__name") + +admin.site.register(models.ApplicationToken, ApplicationTokenAdmin) diff --git a/taiga/external_apps/api.py b/taiga/external_apps/api.py new file mode 100644 index 000000000..8679128c2 --- /dev/null +++ b/taiga/external_apps/api.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from . import serializers +from . import validators +from . import models +from . import permissions +from . import services + +from taiga.base import response +from taiga.base import exceptions as exc +from taiga.base.api import ModelCrudViewSet, ModelRetrieveViewSet +from taiga.base.api.utils import get_object_or_404 +from taiga.base.decorators import list_route, detail_route + +from django.utils.translation import gettext_lazy as _ + + +class Application(ModelRetrieveViewSet): + serializer_class = serializers.ApplicationSerializer + validator_class = validators.ApplicationValidator + permission_classes = (permissions.ApplicationPermission,) + model = models.Application + + @detail_route(methods=["GET"]) + def token(self, request, *args, **kwargs): + if self.request.user.is_anonymous: + raise exc.NotAuthenticated(_("Authentication required")) + + application = get_object_or_404(models.Application, **kwargs) + self.check_permissions(request, 'token', request.user) + try: + application_token = models.ApplicationToken.objects.get(user=request.user, application=application) + application_token.update_auth_code() + application_token.state = request.GET.get("state", None) + application_token.save() + + except models.ApplicationToken.DoesNotExist: + application_token = models.ApplicationToken( + user=request.user, + application=application + ) + + auth_code_data = serializers.ApplicationTokenSerializer(application_token).data + return response.Ok(auth_code_data) + + +class ApplicationToken(ModelCrudViewSet): + serializer_class = serializers.ApplicationTokenSerializer + validator_class = validators.ApplicationTokenValidator + permission_classes = (permissions.ApplicationTokenPermission,) + + def get_queryset(self): + if self.request.user.is_anonymous: + raise exc.NotAuthenticated(_("Authentication required")) + + queryset = models.ApplicationToken.objects.filter(user=self.request.user) + + application_id = self.request.QUERY_PARAMS.get("application", None) + if application_id: + queryset = queryset.filter(application_id=application_id) + return queryset + + @list_route(methods=["POST"]) + def authorize(self, request, pk=None): + if self.request.user.is_anonymous: + raise exc.NotAuthenticated(_("Authentication required")) + + application_id = request.DATA.get("application", None) + state = request.DATA.get("state", None) + application_token = services.authorize_token(application_id, request.user, state) + + auth_code_data = serializers.AuthorizationCodeSerializer(application_token).data + return response.Ok(auth_code_data) + + @list_route(methods=["POST"]) + def validate(self, request, pk=None): + application_id = request.DATA.get("application", None) + auth_code = request.DATA.get("auth_code", None) + state = request.DATA.get("state", None) + application_token = get_object_or_404(models.ApplicationToken, + application__id=application_id, + auth_code=auth_code, + state=state) + + application_token.generate_token() + application_token.save() + + access_token_data = serializers.AccessTokenSerializer(application_token).data + return response.Ok(access_token_data) + + # POST method disabled + def create(self, *args, **kwargs): + raise exc.NotSupported() + + # PATCH and PUT methods disabled + def update(self, *args, **kwargs): + raise exc.NotSupported() diff --git a/taiga/external_apps/auth_backends.py b/taiga/external_apps/auth_backends.py new file mode 100644 index 000000000..939b582b9 --- /dev/null +++ b/taiga/external_apps/auth_backends.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import re + +from taiga.base.api.authentication import BaseAuthentication + +from . import services + +class Token(BaseAuthentication): + auth_rx = re.compile(r"^Application (.+)$") + + def authenticate(self, request): + if "authorization" not in request.headers: + return None + + token_rx_match = self.auth_rx.search(request.headers["authorization"]) + if not token_rx_match: + return None + + token = token_rx_match.group(1) + user = services.get_user_for_application_token(token) + + return (user, token) + + def authenticate_header(self, request): + return 'Bearer realm="api"' diff --git a/taiga/external_apps/management/__init__.py b/taiga/external_apps/management/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/external_apps/management/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/external_apps/management/commands/__init__.py b/taiga/external_apps/management/commands/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/external_apps/management/commands/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/external_apps/management/commands/create_taiga_tribe_app.py b/taiga/external_apps/management/commands/create_taiga_tribe_app.py new file mode 100644 index 000000000..a62577f9c --- /dev/null +++ b/taiga/external_apps/management/commands/create_taiga_tribe_app.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from taiga.external_apps.models import Application + + +class Command(BaseCommand): + args = '' + help = 'Create Taiga Tribe external app information' + + def handle(self, *args, **options): + Application.objects.get_or_create( + id="8836b290-9f45-11e5-958e-52540016141a", + name="Taiga Tribe", + icon_url="https://tribe.taiga.io/static/common/graphics/logo/reindeer-color.png", + web="https://tribe.taiga.io", + description="A task-based employment marketplace for software development.", + next_url="https://tribe.taiga.io/taiga-integration", + ) diff --git a/taiga/external_apps/migrations/0001_initial.py b/taiga/external_apps/migrations/0001_initial.py new file mode 100644 index 000000000..1159aa976 --- /dev/null +++ b/taiga/external_apps/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import taiga.external_apps.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Application', + fields=[ + ('id', models.CharField(serialize=False, unique=True, max_length=255, default=taiga.external_apps.models._generate_uuid, primary_key=True)), + ('name', models.CharField(verbose_name='name', max_length=255)), + ('icon_url', models.TextField(null=True, blank=True, verbose_name='Icon url')), + ('web', models.CharField(null=True, blank=True, max_length=255, verbose_name='web')), + ('description', models.TextField(null=True, blank=True, verbose_name='description')), + ('next_url', models.TextField(verbose_name='Next url')), + ('key', models.TextField(verbose_name='secret key for ciphering the application tokens')), + ], + options={ + 'verbose_name_plural': 'applications', + 'verbose_name': 'application', + 'ordering': ['name'], + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='ApplicationToken', + fields=[ + ('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), + ('auth_code', models.CharField(null=True, blank=True, max_length=255, default=None)), + ('token', models.CharField(null=True, blank=True, max_length=255, default=None)), + ('state', models.CharField(null=True, blank=True, max_length=255, default='')), + ('application', models.ForeignKey(verbose_name='application', related_name='application_tokens', to='external_apps.Application', on_delete=models.CASCADE)), + ('user', models.ForeignKey(verbose_name='user', related_name='application_tokens', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='applicationtoken', + unique_together=set([('application', 'user')]), + ), + ] diff --git a/taiga/external_apps/migrations/0002_remove_application_key.py b/taiga/external_apps/migrations/0002_remove_application_key.py new file mode 100644 index 000000000..5df4d7785 --- /dev/null +++ b/taiga/external_apps/migrations/0002_remove_application_key.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-09-14 14:13 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('external_apps', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='application', + name='key', + ), + ] diff --git a/taiga/external_apps/migrations/0003_auto_20170607_2320.py b/taiga/external_apps/migrations/0003_auto_20170607_2320.py new file mode 100644 index 000000000..5979bf38e --- /dev/null +++ b/taiga/external_apps/migrations/0003_auto_20170607_2320.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.2 on 2017-06-07 23:20 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('external_apps', '0002_remove_application_key'), + ] + + operations = [ + migrations.AlterModelOptions( + name='application', + options={'ordering': ['name', 'id'], 'verbose_name': 'application', 'verbose_name_plural': 'applications'}, + ), + migrations.AlterModelOptions( + name='applicationtoken', + options={'ordering': ['application', 'user'], 'verbose_name': 'application token', 'verbose_name_plural': 'application tolens'}, + ), + ] diff --git a/taiga/external_apps/migrations/0004_typo_fix.py b/taiga/external_apps/migrations/0004_typo_fix.py new file mode 100644 index 000000000..120c218a8 --- /dev/null +++ b/taiga/external_apps/migrations/0004_typo_fix.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.28 on 2020-02-05 20:51 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('external_apps', '0003_auto_20170607_2320'), + ] + + operations = [ + migrations.AlterModelOptions( + name='applicationtoken', + options={'ordering': ['application', 'user'], 'verbose_name': 'application token', 'verbose_name_plural': 'application tokens'}, + ), + ] diff --git a/taiga/external_apps/migrations/__init__.py b/taiga/external_apps/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/external_apps/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/external_apps/models.py b/taiga/external_apps/models.py new file mode 100644 index 000000000..43e33f5f1 --- /dev/null +++ b/taiga/external_apps/models.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.core import signing +from django.db import models +from django.utils.translation import gettext_lazy as _ + +import uuid + + +def _generate_uuid(): + return str(uuid.uuid4()) + + +class Application(models.Model): + id = models.CharField(primary_key=True, max_length=255, unique=True, default=_generate_uuid) + + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + + icon_url = models.TextField(null=True, blank=True, verbose_name=_("Icon url")) + web = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("web")) + description = models.TextField(null=True, blank=True, verbose_name=_("description")) + + next_url = models.TextField(null=False, blank=False, verbose_name=_("Next url")) + + class Meta: + verbose_name = "application" + verbose_name_plural = "applications" + ordering = ["name", "id"] + + def __str__(self): + return self.name + + +class ApplicationToken(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=False, + null=False, + related_name="application_tokens", + verbose_name=_("user"), + on_delete=models.CASCADE, + ) + + application = models.ForeignKey( + "Application", + blank=False, + null=False, + related_name="application_tokens", + verbose_name=_("application"), + on_delete=models.CASCADE, + ) + + auth_code = models.CharField(max_length=255, null=True, blank=True, default=None) + token = models.CharField(max_length=255, null=True, blank=True, default=None) + # An unguessable random string. It is used to protect against cross-site request forgery attacks. + state = models.CharField(max_length=255, null=True, blank=True, default="") + + class Meta: + verbose_name = "application token" + verbose_name_plural = "application tokens" + ordering = ["application", "user",] + unique_together = ("application", "user",) + + def __str__(self): + return "{application}: {user} - {token}".format(application=self.application.name, user=self.user.get_full_name(), token=self.token) + + @property + def next_url(self): + return "{url}?auth_code={auth_code}".format(url=self.application.next_url, auth_code=self.auth_code) + + def update_auth_code(self): + self.auth_code = _generate_uuid() + + def generate_token(self): + self.auth_code = None + if not self.token: + data = {"app_token_id": self.pk} + self.token = signing.dumps(data) diff --git a/taiga/external_apps/permissions.py b/taiga/external_apps/permissions.py new file mode 100644 index 000000000..8a332d6cc --- /dev/null +++ b/taiga/external_apps/permissions.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission +from taiga.base.api.permissions import IsAuthenticated +from taiga.base.api.permissions import PermissionComponent + + +class ApplicationPermission(TaigaResourcePermission): + retrieve_perms = IsAuthenticated() + token_perms = IsAuthenticated() + list_perms = IsAuthenticated() + + +class CanUseToken(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if not obj: + return False + + return request.user == obj.user + + +class ApplicationTokenPermission(TaigaResourcePermission): + retrieve_perms = IsAuthenticated() & CanUseToken() + by_application_perms = IsAuthenticated() + create_perms = IsAuthenticated() + update_perms = IsAuthenticated() & CanUseToken() + partial_update_perms = IsAuthenticated() & CanUseToken() + destroy_perms = IsAuthenticated() & CanUseToken() + list_perms = IsAuthenticated() diff --git a/taiga/external_apps/serializers.py b/taiga/external_apps/serializers.py new file mode 100644 index 000000000..161d42dfa --- /dev/null +++ b/taiga/external_apps/serializers.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field + +from . import models +from . import services + +from django.utils.translation import gettext as _ + + +class ApplicationSerializer(serializers.LightSerializer): + id = Field() + name = Field() + web = Field() + description = Field() + icon_url = Field() + + +class ApplicationTokenSerializer(serializers.LightSerializer): + id = Field() + user = Field(attr="user_id") + application = ApplicationSerializer() + auth_code = Field() + next_url = Field() + + +class AuthorizationCodeSerializer(serializers.LightSerializer): + state = Field() + auth_code = Field() + next_url = Field() + + +class AccessTokenSerializer(serializers.LightSerializer): + token = Field() diff --git a/taiga/external_apps/services.py b/taiga/external_apps/services.py new file mode 100644 index 000000000..2e8496c3a --- /dev/null +++ b/taiga/external_apps/services.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import exceptions as exc +from taiga.base.api.utils import get_object_or_404 + +from django.apps import apps +from django.utils.translation import gettext as _ + + +def get_user_for_application_token(token:str) -> object: + """ + Given an application token it tries to find an associated user + """ + app_token = apps.get_model("external_apps", "ApplicationToken").objects.filter(token=token).first() + if not app_token: + raise exc.NotAuthenticated(_("Invalid token")) + return app_token.user + + +def authorize_token(application_id:int, user:object, state:str) -> object: + ApplicationToken = apps.get_model("external_apps", "ApplicationToken") + Application = apps.get_model("external_apps", "Application") + application = get_object_or_404(Application, id=application_id) + token, _ = ApplicationToken.objects.get_or_create(user=user, application=application) + token.update_auth_code() + token.state = state + token.save() + return token diff --git a/taiga/external_apps/validators.py b/taiga/external_apps/validators.py new file mode 100644 index 000000000..4f53c7a5d --- /dev/null +++ b/taiga/external_apps/validators.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers + +from . import models +from taiga.base.api import validators + + +class ApplicationValidator(validators.ModelValidator): + class Meta: + model = models.Application + fields = ("id", "name", "web", "description", "icon_url") + + +class ApplicationTokenValidator(validators.ModelValidator): + token = serializers.CharField(source="token", read_only=True) + next_url = serializers.CharField(source="next_url", read_only=True) + application = ApplicationValidator(read_only=True) + + class Meta: + model = models.ApplicationToken + fields = ("user", "id", "application", "auth_code", "next_url") + + +class AuthorizationCodeValidator(validators.ModelValidator): + next_url = serializers.CharField(source="next_url", read_only=True) + class Meta: + model = models.ApplicationToken + fields = ("auth_code", "state", "next_url") + + +class AccessTokenValidator(validators.ModelValidator): + token = serializers.CharField(source="token", read_only=True) + next_url = serializers.CharField(source="next_url", read_only=True) + + class Meta: + model = models.ApplicationToken + fields = ("token", ) diff --git a/taiga/feedback/__init__.py b/taiga/feedback/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/feedback/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/feedback/admin.py b/taiga/feedback/admin.py new file mode 100644 index 000000000..bba04aa48 --- /dev/null +++ b/taiga/feedback/admin.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin + +from . import models + + +class FeedbackEntryAdmin(admin.ModelAdmin): + list_display = ['created_date', 'full_name', 'email' ] + list_display_links = list_display + list_filter = ['created_date',] + date_hierarchy = "created_date" + ordering = ("-created_date", "id") + search_fields = ("full_name", "email", "id") + + +admin.site.register(models.FeedbackEntry, FeedbackEntryAdmin) diff --git a/taiga/feedback/api.py b/taiga/feedback/api.py new file mode 100644 index 000000000..4ede86392 --- /dev/null +++ b/taiga/feedback/api.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import response +from taiga.base.api import viewsets + +from . import permissions +from . import validators +from . import services + +import copy + + +class FeedbackViewSet(viewsets.ViewSet): + permission_classes = (permissions.FeedbackPermission,) + validator_class = validators.FeedbackEntryValidator + + def create(self, request, **kwargs): + self.check_permissions(request, "create", None) + + data = copy.deepcopy(request.DATA) + data.update({"full_name": request.user.get_full_name(), + "email": request.user.email}) + + validator = self.validator_class(data=data) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + self.object = validator.save(force_insert=True) + + extra = { + "HTTP_HOST": request.headers.get("host", None), + "HTTP_REFERER": request.headers.get("referer", None), + "HTTP_USER_AGENT": request.headers.get("user-agent", None), + } + services.send_feedback(self.object, extra, reply_to=[request.user.email]) + + return response.Ok(validator.data) diff --git a/taiga/feedback/apps.py b/taiga/feedback/apps.py new file mode 100644 index 000000000..3f2c7ee35 --- /dev/null +++ b/taiga/feedback/apps.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig +from django.apps import apps +from django.conf import settings +from django.urls import include, path + + +class FeedbackAppConfig(AppConfig): + name = "taiga.feedback" + verbose_name = "Feedback" + + def ready(self): + if settings.FEEDBACK_ENABLED: + from taiga.urls import urlpatterns + from .routers import router + urlpatterns.append(path('api/v1/', include(router.urls))) diff --git a/taiga/feedback/migrations/0001_initial.py b/taiga/feedback/migrations/0001_initial.py new file mode 100644 index 000000000..8c01e695a --- /dev/null +++ b/taiga/feedback/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='FeedbackEntry', + fields=[ + ('id', models.AutoField(primary_key=True, auto_created=True, verbose_name='ID', serialize=False)), + ('full_name', models.CharField(verbose_name='full name', max_length=256)), + ('email', models.EmailField(verbose_name='email address', max_length=255)), + ('comment', models.TextField(verbose_name='comment')), + ('created_date', models.DateTimeField(auto_now_add=True, verbose_name='created date')), + ], + options={ + 'verbose_name': 'feedback entry', + 'verbose_name_plural': 'feedback entries', + 'ordering': ['-created_date', 'id'], + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/feedback/migrations/__init__.py b/taiga/feedback/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/feedback/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/feedback/models.py b/taiga/feedback/models.py new file mode 100644 index 000000000..4363e88ad --- /dev/null +++ b/taiga/feedback/models.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class FeedbackEntry(models.Model): + full_name = models.CharField(null=False, blank=False, max_length=256, + verbose_name=_('full name')) + email = models.EmailField(null=False, blank=False, max_length=255, + verbose_name=_('email address')) + comment = models.TextField(null=False, blank=False, + verbose_name=_("comment")) + created_date = models.DateTimeField(null=False, blank=False, auto_now_add=True, + verbose_name=_("created date")) + + class Meta: + verbose_name = "feedback entry" + verbose_name_plural = "feedback entries" + ordering = ["-created_date", "id"] diff --git a/taiga/feedback/permissions.py b/taiga/feedback/permissions.py new file mode 100644 index 000000000..035be4788 --- /dev/null +++ b/taiga/feedback/permissions.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission +from taiga.base.api.permissions import IsAuthenticated + + +class FeedbackPermission(TaigaResourcePermission): + create_perms = IsAuthenticated() diff --git a/taiga/feedback/routers.py b/taiga/feedback/routers.py new file mode 100644 index 000000000..46dfb1979 --- /dev/null +++ b/taiga/feedback/routers.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import routers +from . import api + + +router = routers.DefaultRouter(trailing_slash=False) +router.register(r"feedback", api.FeedbackViewSet, base_name="feedback") diff --git a/taiga/feedback/services.py b/taiga/feedback/services.py new file mode 100644 index 000000000..5e020e97d --- /dev/null +++ b/taiga/feedback/services.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings + +from taiga.base.mails import mail_builder + + +def send_feedback(feedback_entry, extra, reply_to=[]): + support_email = settings.FEEDBACK_EMAIL + + if support_email: + reply_to.append(support_email) + + ctx = { + "feedback_entry": feedback_entry, + "extra": extra + } + + email = mail_builder.feedback_notification(support_email, ctx) + email.extra_headers["Reply-To"] = ", ".join(reply_to) + email.send() diff --git a/taiga/feedback/templates/emails/feedback_notification-body-html.jinja b/taiga/feedback/templates/emails/feedback_notification-body-html.jinja new file mode 100644 index 000000000..b4ee21aa7 --- /dev/null +++ b/taiga/feedback/templates/emails/feedback_notification-body-html.jinja @@ -0,0 +1,41 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans full_name=feedback_entry.full_name, email=feedback_entry.email %} +

Feedback

+

Taiga has received feedback from {{ full_name }} <{{ email }}>

+ {% endtrans %} + + {% trans comment=feedback_entry.comment|linebreaksbr %} +

Comment

+

{{ comment }}

+ {% endtrans %} + + {% if extra %} + + + + +
+

{{ _("Extra info") }}

+
+ {% for k, v in extra.items() %} +
{{ k }}
+
{{ v }}
+ {% endfor %} +
+
+ {% endif %} + + {% trans signature=sr("signature") %} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/feedback/templates/emails/feedback_notification-body-text.jinja b/taiga/feedback/templates/emails/feedback_notification-body-text.jinja new file mode 100644 index 000000000..8eeff2eea --- /dev/null +++ b/taiga/feedback/templates/emails/feedback_notification-body-text.jinja @@ -0,0 +1,25 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), full_name=feedback_entry.full_name, email=feedback_entry.email, comment=feedback_entry.comment %}--------- +- From: {{ full_name }} <{{ email }}> +--------- +- Comment: +{{ comment }} +---------{% endtrans %} +{% if extra %} +{{ _("- Extra info:") }} +{% for k, v in extra.items() %} + - {{ k }}: {{ v }} +{% endfor %} +{% endif %}---------- + +{% trans signature=sr("signature") %} +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/feedback/templates/emails/feedback_notification-subject.jinja b/taiga/feedback/templates/emails/feedback_notification-subject.jinja new file mode 100644 index 000000000..e114c8601 --- /dev/null +++ b/taiga/feedback/templates/emails/feedback_notification-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans full_name=feedback_entry.full_name|safe, email=feedback_entry.email|safe %} +[Taiga] Feedback from {{ full_name }} <{{ email }}> +{% endtrans %} diff --git a/taiga/feedback/validators.py b/taiga/feedback/validators.py new file mode 100644 index 000000000..79e2fd15a --- /dev/null +++ b/taiga/feedback/validators.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import validators + +from . import models + + +class FeedbackEntryValidator(validators.ModelValidator): + class Meta: + model = models.FeedbackEntry diff --git a/taiga/front/__init__.py b/taiga/front/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/front/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/front/models.py b/taiga/front/models.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/front/models.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/front/sitemaps/__init__.py b/taiga/front/sitemaps/__init__.py new file mode 100644 index 000000000..fe2ebf85b --- /dev/null +++ b/taiga/front/sitemaps/__init__.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from collections import OrderedDict + +from .generics import GenericSitemap + +from .projects import ProjectsSitemap +from .projects import ProjectBacklogsSitemap +from .projects import ProjectKanbansSitemap + +from .epics import EpicsSitemap + +from .milestones import MilestonesSitemap + +from .userstories import UserStoriesSitemap + +from .tasks import TasksSitemap + +from .issues import IssuesSitemap + +from .wiki import WikiPagesSitemap + +from .users import UsersSitemap + + +sitemaps = OrderedDict([ + ("generics", GenericSitemap), + + ("projects", ProjectsSitemap), + ("project-backlogs", ProjectBacklogsSitemap), + ("project-kanbans", ProjectKanbansSitemap), + + ("epics", EpicsSitemap), + + ("milestones", MilestonesSitemap), + + ("userstories", UserStoriesSitemap), + + ("tasks", TasksSitemap), + + ("issues", IssuesSitemap), + + ("wikipages", WikiPagesSitemap), + + ("users", UsersSitemap) +]) diff --git a/taiga/front/sitemaps/base.py b/taiga/front/sitemaps/base.py new file mode 100644 index 000000000..87284c798 --- /dev/null +++ b/taiga/front/sitemaps/base.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.contrib.sitemaps import Sitemap as DjangoSitemap + + +class Sitemap(DjangoSitemap): + limit = settings.FRONT_SITEMAP_PAGE_SIZE + + def get_urls(self, page=1, site=None, protocol=None): + urls = [] + latest_lastmod = None + all_items_lastmod = True # track if all items have a lastmod + for item in self.paginator.page(page).object_list: + loc = self._get('location', item) + priority = self._get('priority', item, None) + lastmod = self._get('lastmod', item, None) + changefreq = self._get('changefreq', item, None) + if all_items_lastmod: + all_items_lastmod = lastmod is not None + if (all_items_lastmod and + (latest_lastmod is None or lastmod > latest_lastmod)): + latest_lastmod = lastmod + url_info = { + 'item': item, + 'location': loc, + 'lastmod': lastmod, + 'changefreq': changefreq, + 'priority': str(priority if priority is not None else ''), + } + urls.append(url_info) + if all_items_lastmod and latest_lastmod: + self.latest_lastmod = latest_lastmod + + return urls diff --git a/taiga/front/sitemaps/epics.py b/taiga/front/sitemaps/epics.py new file mode 100644 index 000000000..4243fbe5f --- /dev/null +++ b/taiga/front/sitemaps/epics.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import Q +from django.apps import apps +from datetime import timedelta +from django.utils import timezone + +from taiga.front.templatetags.functions import resolve + +from .base import Sitemap + + +class EpicsSitemap(Sitemap): + def items(self): + epic_model = apps.get_model("epics", "Epic") + + # Get epics of public projects OR private projects if anon user can view them + queryset = epic_model.objects.filter(Q(project__is_private=False) | + Q(project__is_private=True, + project__anon_permissions__contains=["view_epics"])) + + queryset = queryset.exclude(description="") + queryset = queryset.exclude(description__isnull=True) + + # Exclude blocked projects + queryset = queryset.filter(project__blocked_code__isnull=True) + + # Project data is needed + queryset = queryset.select_related("project") + + return queryset + + def location(self, obj): + return resolve("epic", obj.project.slug, obj.ref) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + if (timezone.now() - obj.modified_date) > timedelta(days=90): + return "monthly" + return "weekly" + + def priority(self, obj): + return 0.5 diff --git a/taiga/front/sitemaps/generics.py b/taiga/front/sitemaps/generics.py new file mode 100644 index 000000000..5f07da350 --- /dev/null +++ b/taiga/front/sitemaps/generics.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import Q +from django.apps import apps + +from taiga.front.templatetags.functions import resolve + +from .base import Sitemap + + +class GenericSitemap(Sitemap): + def items(self): + return [ + {"url_key": "home", "changefreq": "monthly", "priority": 1}, + {"url_key": "discover", "changefreq": "daily", "priority": 1}, + {"url_key": "login", "changefreq": "monthly", "priority": 1}, + {"url_key": "register", "changefreq": "monthly", "priority": 1}, + {"url_key": "forgot-password", "changefreq": "monthly", "priority": 1} + ] + + def location(self, obj): + return resolve(obj["url_key"]) + + def changefreq(self, obj): + return obj.get("changefreq", None) + + def priority(self, obj): + return obj.get("priority", None) + diff --git a/taiga/front/sitemaps/issues.py b/taiga/front/sitemaps/issues.py new file mode 100644 index 000000000..f6cba13eb --- /dev/null +++ b/taiga/front/sitemaps/issues.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import Q +from django.apps import apps +from datetime import timedelta +from django.utils import timezone + +from taiga.front.templatetags.functions import resolve + +from .base import Sitemap + + +class IssuesSitemap(Sitemap): + def items(self): + issue_model = apps.get_model("issues", "Issue") + + # Get issues of public projects OR private projects if anon user can view them + queryset = issue_model.objects.filter(Q(project__is_private=False) | + Q(project__is_private=True, + project__anon_permissions__contains=["view_issues"])) + + # Exclude blocked projects + queryset = queryset.filter(project__blocked_code__isnull=True) + + # Project data is needed + queryset = queryset.select_related("project") + + return queryset + + def location(self, obj): + return resolve("issue", obj.project.slug, obj.ref) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + if (timezone.now() - obj.modified_date) > timedelta(days=90): + return "monthly" + return "weekly" + + def priority(self, obj): + return 0.5 diff --git a/taiga/front/sitemaps/milestones.py b/taiga/front/sitemaps/milestones.py new file mode 100644 index 000000000..1430f3194 --- /dev/null +++ b/taiga/front/sitemaps/milestones.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import Q +from django.apps import apps +from datetime import timedelta +from django.utils import timezone + +from taiga.front.templatetags.functions import resolve + +from .base import Sitemap + + +class MilestonesSitemap(Sitemap): + def items(self): + milestone_model = apps.get_model("milestones", "Milestone") + + # Get US of public projects OR private projects if anon user can view them and us and tasks + queryset = milestone_model.objects.filter(Q(project__is_private=False) | + Q(project__is_private=True, + project__anon_permissions__contains=["view_milestones", + "view_us", + "view_tasks"])) + + queryset = queryset.exclude(name="") + + # Exclude blocked projects + queryset = queryset.filter(project__blocked_code__isnull=True) + + # Project data is needed + queryset = queryset.select_related("project") + + return queryset + + def location(self, obj): + return resolve("taskboard", obj.project.slug, obj.slug) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + if (timezone.now() - obj.modified_date) > timedelta(days=90): + return "monthly" + return "weekly" + + def priority(self, obj): + return 0.1 diff --git a/taiga/front/sitemaps/projects.py b/taiga/front/sitemaps/projects.py new file mode 100644 index 000000000..9d29608ee --- /dev/null +++ b/taiga/front/sitemaps/projects.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import Q +from django.apps import apps + +from taiga.front.templatetags.functions import resolve +from datetime import timedelta +from django.utils import timezone + +from .base import Sitemap + + +class ProjectsSitemap(Sitemap): + def items(self): + project_model = apps.get_model("projects", "Project") + + # Get public projects OR private projects if anon user can view them + queryset = project_model.objects.filter(Q(is_private=False) | + Q(is_private=True, + anon_permissions__contains=["view_project"])) + + # Exclude blocked projects + queryset = queryset.filter(blocked_code__isnull=True) + queryset = queryset.exclude(description="") + queryset = queryset.exclude(description__isnull=True) + queryset = queryset.exclude(total_activity__gt=5) + + return queryset + + def location(self, obj): + return resolve("project", obj.slug) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + if (timezone.now() - obj.modified_date) > timedelta(days=30): + return "monthly" + return "daily" + + def priority(self, obj): + return 0.8 + + +class ProjectBacklogsSitemap(Sitemap): + def items(self): + project_model = apps.get_model("projects", "Project") + + # Get public projects OR private projects if anon user can view them and user stories + queryset = project_model.objects.filter(Q(is_private=False) | + Q(is_private=True, + anon_permissions__contains=["view_project", + "view_us"])) + + queryset = queryset.exclude(description="") + queryset = queryset.exclude(description__isnull=True) + queryset = queryset.exclude(total_activity__gt=5) + + # Exclude projects without backlog enabled + queryset = queryset.exclude(is_backlog_activated=False) + + return queryset + + def location(self, obj): + return resolve("backlog", obj.slug) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + if (timezone.now() - obj.modified_date) > timedelta(days=90): + return "monthly" + return "weekly" + + def priority(self, obj): + return 0.1 + + +class ProjectKanbansSitemap(Sitemap): + def items(self): + project_model = apps.get_model("projects", "Project") + + # Get public projects OR private projects if anon user can view them and user stories + queryset = project_model.objects.filter(Q(is_private=False) | + Q(is_private=True, + anon_permissions__contains=["view_project", + "view_us"])) + + queryset = queryset.exclude(description="") + queryset = queryset.exclude(description__isnull=True) + queryset = queryset.exclude(total_activity__gt=5) + + # Exclude projects without kanban enabled + queryset = queryset.exclude(is_kanban_activated=False) + + return queryset + + def location(self, obj): + return resolve("kanban", obj.slug) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + if (timezone.now() - obj.modified_date) > timedelta(days=90): + return "monthly" + return "weekly" + + def priority(self, obj): + return 0.1 diff --git a/taiga/front/sitemaps/tasks.py b/taiga/front/sitemaps/tasks.py new file mode 100644 index 000000000..050824df3 --- /dev/null +++ b/taiga/front/sitemaps/tasks.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import Q +from django.apps import apps +from datetime import timedelta +from django.utils import timezone + +from taiga.front.templatetags.functions import resolve + +from .base import Sitemap + + +class TasksSitemap(Sitemap): + def items(self): + task_model = apps.get_model("tasks", "Task") + + # Get tasks of public projects OR private projects if anon user can view them + queryset = task_model.objects.filter(Q(project__is_private=False) | + Q(project__is_private=True, + project__anon_permissions__contains=["view_tasks"])) + + # Exclude blocked projects + queryset = queryset.filter(project__blocked_code__isnull=True) + + # Project data is needed + queryset = queryset.select_related("project") + + return queryset + + def location(self, obj): + return resolve("task", obj.project.slug, obj.ref) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + if (timezone.now() - obj.modified_date) > timedelta(days=90): + return "monthly" + return "weekly" + + def priority(self, obj): + return 0.5 diff --git a/taiga/front/sitemaps/users.py b/taiga/front/sitemaps/users.py new file mode 100644 index 000000000..8dd4ebd32 --- /dev/null +++ b/taiga/front/sitemaps/users.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.contrib.auth import get_user_model + +from taiga.front.templatetags.functions import resolve + +from .base import Sitemap + + +class UsersSitemap(Sitemap): + def items(self): + user_model = get_user_model() + + # Only active users and not system users + queryset = user_model.objects.filter(is_active=True, + is_system=False) + + return queryset + + def location(self, obj): + return resolve("user", obj.username) + + def lastmod(self, obj): + return None + + def changefreq(self, obj): + return "weekly" + + def priority(self, obj): + return 0.5 diff --git a/taiga/front/sitemaps/userstories.py b/taiga/front/sitemaps/userstories.py new file mode 100644 index 000000000..996c9e747 --- /dev/null +++ b/taiga/front/sitemaps/userstories.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import Q +from django.apps import apps +from datetime import timedelta +from django.utils import timezone + +from taiga.front.templatetags.functions import resolve + +from .base import Sitemap + + +class UserStoriesSitemap(Sitemap): + def items(self): + us_model = apps.get_model("userstories", "UserStory") + + # Get US of public projects OR private projects if anon user can view them + queryset = us_model.objects.filter(Q(project__is_private=False) | + Q(project__is_private=True, + project__anon_permissions__contains=["view_us"])) + + # Exclude blocked projects + queryset = queryset.filter(project__blocked_code__isnull=True) + + # Project data is needed + queryset = queryset.select_related("project") + + return queryset + + def location(self, obj): + return resolve("userstory", obj.project.slug, obj.ref) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + if (timezone.now() - obj.modified_date) > timedelta(days=90): + return "monthly" + return "weekly" + + def priority(self, obj): + return 0.5 diff --git a/taiga/front/sitemaps/wiki.py b/taiga/front/sitemaps/wiki.py new file mode 100644 index 000000000..f02dabd69 --- /dev/null +++ b/taiga/front/sitemaps/wiki.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import Q +from django.apps import apps +from datetime import timedelta +from django.utils import timezone + +from taiga.front.templatetags.functions import resolve + +from .base import Sitemap + + +class WikiPagesSitemap(Sitemap): + def items(self): + wiki_page_model = apps.get_model("wiki", "WikiPage") + + # Get wiki pages of public projects OR private projects if anon user can view them + queryset = wiki_page_model.objects.filter(Q(project__is_private=False) | + Q(project__is_private=True, + project__anon_permissions__contains=["view_wiki_pages"])) + + # Exclude blocked projects + queryset = queryset.filter(project__blocked_code__isnull=True) + + # Exclude wiki pages from projects without wiki section enabled + queryset = queryset.exclude(project__is_wiki_activated=False) + + # Project data is needed + queryset = queryset.select_related("project") + + return queryset + + def location(self, obj): + return resolve("wiki", obj.project.slug, obj.slug) + + def lastmod(self, obj): + return obj.modified_date + + def changefreq(self, obj): + if (timezone.now() - obj.modified_date) > timedelta(days=90): + return "monthly" + return "weekly" + + def priority(self, obj): + return 0.6 diff --git a/taiga/front/templatetags/__init__.py b/taiga/front/templatetags/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/front/templatetags/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/front/templatetags/functions.py b/taiga/front/templatetags/functions.py new file mode 100644 index 000000000..faad580b6 --- /dev/null +++ b/taiga/front/templatetags/functions.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime as dt + +from django_jinja import library +from django_sites import get_by_id as get_site_by_id + +from taiga.front.urls import urls + + +@library.global_function(name="resolve_front_url") +def resolve(type, *args): + site = get_site_by_id("front") + url_tmpl = "{scheme}//{domain}{url}" + + scheme = site.scheme and "{0}:".format(site.scheme) or "" + url = urls[type].format(*args) + return url_tmpl.format(scheme=scheme, domain=site.domain, url=url) + + +@library.filter(name="parse_and_format_date") +def parse_and_format_date(value, *args): + date_value = dt.datetime.strptime(value, '%Y-%m-%d') + return date_value.strftime('%d %b %Y') diff --git a/taiga/front/urls.py b/taiga/front/urls.py new file mode 100644 index 000000000..33b29c079 --- /dev/null +++ b/taiga/front/urls.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +urls = { + "home": "/", + "discover": "/discover", + "login": "/login", + "register": "/register", + "forgot-password": "/forgot-password", + "new-project": "/project/new", + "new-project-import": "/project/new/import/{0}", + "settings-mail-notifications": "/user-settings/mail-notifications", + + "change-password": "/change-password/{0}", # user.token + "change-email": "/change-email/{0}", # user.email_token + "verify-email": "/verify-email/{0}", # user.email_token + "cancel-account": "/cancel-account/{0}", # auth.tokens.CancelToken.for_user(user) + "invitation": "/invitation/{0}", # membership.token + + "user": "/profile/{0}", # user.username + + "project": "/project/{0}", # project.slug + + "epics": "/project/{0}/epics/", # project.slug + "epic": "/project/{0}/epic/{1}", # project.slug, epic.ref + + "backlog": "/project/{0}/backlog/", # project.slug + "taskboard": "/project/{0}/taskboard/{1}", # project.slug, milestone.slug + "kanban": "/project/{0}/kanban/", # project.slug + + "userstory": "/project/{0}/us/{1}", # project.slug, us.ref + "task": "/project/{0}/task/{1}", # project.slug, task.ref + + "issues": "/project/{0}/issues", # project.slug + "issue": "/project/{0}/issue/{1}", # project.slug, issue.ref + + "wiki": "/project/{0}/wiki/{1}", # project.slug, wikipage.slug + + "team": "/project/{0}/team/", # project.slug + + "project-transfer": "/project/{0}/transfer/{1}", # project.slug, project.transfer_token + + "project-admin": "/login?next=/project/{0}/admin/project-profile/details", # project.slug + + "project-import-jira": "/project/new/import/jira?url={}", +} diff --git a/taiga/hooks/__init__.py b/taiga/hooks/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/hooks/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/hooks/api.py b/taiga/hooks/api.py new file mode 100644 index 000000000..2f7186f39 --- /dev/null +++ b/taiga/hooks/api.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base import exceptions as exc +from taiga.base import response +from taiga.base.api.viewsets import GenericViewSet +from taiga.base.utils import json +from taiga.projects.models import Project + +from .exceptions import ActionSyntaxException + + +class BaseWebhookApiViewSet(GenericViewSet): + # We don't want rest framework to parse the request body and transform it in + # a dict in request.DATA, we need it raw + parser_classes = () + + # This dict associates the event names we are listening for + # with their responsible classes (extending event_hooks.BaseEventHook) + event_hook_classes = {} + + def _validate_signature(self, project, request): + raise NotImplemented + + def _get_project(self, request): + project_id = request.GET.get("project", None) + try: + project = Project.objects.get(id=project_id) + return project + except (ValueError, Project.DoesNotExist): + return None + + def _get_payload(self, request): + try: + payload = json.loads(request.body.decode("utf-8")) + except ValueError: + raise exc.BadRequest(_("The payload is not valid json")) + return payload + + def _get_event_name(self, request): + raise NotImplemented + + def create(self, request, *args, **kwargs): + project = self._get_project(request) + if not project: + raise exc.BadRequest(_("The project doesn't exist")) + + if not self._validate_signature(project, request): + raise exc.BadRequest(_("Bad signature")) + + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + event_name = self._get_event_name(request) + + payload = self._get_payload(request) + + event_hook_class = self.event_hook_classes.get(event_name, None) + if event_hook_class is not None: + event_hook = event_hook_class(project, payload) + try: + event_hook.process_event() + except ActionSyntaxException as e: + raise exc.BadRequest(e) + + return response.NoContent() diff --git a/taiga/hooks/bitbucket/__init__.py b/taiga/hooks/bitbucket/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/hooks/bitbucket/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/hooks/bitbucket/api.py b/taiga/hooks/bitbucket/api.py new file mode 100644 index 000000000..8c51caf30 --- /dev/null +++ b/taiga/hooks/bitbucket/api.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext_lazy as _ +from django.conf import settings + +from taiga.base import exceptions as exc +from taiga.projects.models import Project +from taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks + +from netaddr import all_matching_cidrs +from netaddr.core import AddrFormatError +from urllib.parse import parse_qs +from ipware.ip import get_ip + + +class BitBucketViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "repo:push": event_hooks.PushEventHook, + "issue:created": event_hooks.IssuesEventHook, + "issue:comment_created": event_hooks.IssueCommentEventHook, + } + + def _validate_signature(self, project, request): + secret_key = request.GET.get("key", None) + + if secret_key is None: + return False + + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + project_secret = project.modules_config.config.get("bitbucket", {}).get("secret", "") + if not project_secret: + return False + + bitbucket_config = project.modules_config.config.get("bitbucket", {}) + valid_origin_ips = bitbucket_config.get("valid_origin_ips", + settings.BITBUCKET_VALID_ORIGIN_IPS) + origin_ip = get_ip(request) + matching_origin_ip = True + + if valid_origin_ips: + try: + matching_origin_ip = len(all_matching_cidrs(origin_ip,valid_origin_ips)) > 0 + + except(AddrFormatError, ValueError): + matching_origin_ip = False + + if not matching_origin_ip: + return False + + return project_secret == secret_key + + def _get_event_name(self, request): + return request.headers.get('x-event-key', None) diff --git a/taiga/hooks/bitbucket/event_hooks.py b/taiga/hooks/bitbucket/event_hooks.py new file mode 100644 index 000000000..0889ba1cb --- /dev/null +++ b/taiga/hooks/bitbucket/event_hooks.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import re + +from taiga.hooks.event_hooks import (BaseIssueEventHook, BaseIssueCommentEventHook, BasePushEventHook, + ISSUE_ACTION_CREATE, ISSUE_ACTION_UPDATE, ISSUE_ACTION_DELETE) + + +class BaseBitBucketEventHook(): + platform = "BitBucket" + platform_slug = "bitbucket" + + def replace_bitbucket_references(self, project_url, wiki_text): + if wiki_text is None: + wiki_text = "" + + template = fr"\g<1>[BitBucket#\g<2>]({project_url}/issues/\g<2>)\g<3>" + return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M) + + +class IssuesEventHook(BaseBitBucketEventHook, BaseIssueEventHook): + @property + def action_type(self): + # NOTE: Only CREATE for now + return ISSUE_ACTION_CREATE + + def get_data(self): + description = self.payload.get('issue', {}).get('content', {}).get('raw', '') + project_url = self.payload.get('repository', {}).get('links', {}).get('html', {}).get('href', None) + return { + "number": self.payload.get('issue', {}).get('id', None), + "subject": self.payload.get('issue', {}).get('title', None), + "url": self.payload.get('issue', {}).get('links', {}).get('html', {}).get('href', None), + "user_id": self.payload.get('actor', {}).get('uuid', None), + "user_name": self.payload.get('actor', {}).get('nickname', None), + "user_url": self.payload.get('actor', {}).get('links', {}).get('html', {}).get('href'), + "description": self.replace_bitbucket_references(project_url, description), + } + + +class IssueCommentEventHook(BaseBitBucketEventHook, BaseIssueCommentEventHook): + def get_data(self): + comment_message = self.payload.get('comment', {}).get('content', {}).get('raw', '') + project_url = self.payload.get('repository', {}).get('links', {}).get('html', {}).get('href', None) + issue_url = self.payload.get('issue', {}).get('links', {}).get('html', {}).get('href', None) + comment_id = self.payload.get('comment', {}).get('id', None) + comment_url = f"{issue_url}#comment-{comment_id}" + return { + "number": self.payload.get('issue', {}).get('id', None), + 'url': issue_url, + 'user_id': self.payload.get('actor', {}).get('uuid', None), + 'user_name': self.payload.get('actor', {}).get('nickname', None), + 'user_url': self.payload.get('actor', {}).get('links', {}).get('html', {}).get('href'), + 'comment_url': comment_url, + 'comment_message': self.replace_bitbucket_references(project_url, comment_message) + } + + +class PushEventHook(BaseBitBucketEventHook, BasePushEventHook): + def get_data(self): + result = [] + changes = self.payload.get("push", {}).get('changes', []) + for change in filter(None, changes): + for commit in change.get("commits", []): + message = commit.get("message") + result.append({ + 'user_id': commit.get('author', {}).get('user', {}).get('uuid', None), + "user_name": commit.get('author', {}).get('user', {}).get('nickname', None), + "user_url": commit.get('author', {}).get('user', {}).get('links', {}).get('html', {}).get('href'), + "commit_id": commit.get("hash", None), + "commit_url": commit.get("links", {}).get('html', {}).get('href'), + "commit_message": message.strip(), + "commit_short_message": message.split("\n")[0].strip(), + }) + return result diff --git a/taiga/hooks/bitbucket/migrations/0001_initial.py b/taiga/hooks/bitbucket/migrations/0001_initial.py new file mode 100644 index 000000000..17e27e6a9 --- /dev/null +++ b/taiga/hooks/bitbucket/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals +import os.path +import uuid + +from django.conf import settings +from django.core.files import File +from django.db import migrations + + +def create_bitbucket_system_user(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version + User = apps.get_model("users", "User") + db_alias = schema_editor.connection.alias + random_hash = uuid.uuid4().hex + user = User.objects.using(db_alias).create( + username="bitbucket-{}".format(random_hash), + email="bitbucket-{}@taiga.io".format(random_hash), + full_name="BitBucket", + is_active=False, + is_system=True, + bio="", + ) + f = open(os.path.join(settings.BASE_DIR, "taiga/hooks/bitbucket/migrations/logo.png"), "rb") + user.photo.save("logo.png", File(f)) + user.save() + f.close() + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_auto_20141030_1132') + ] + + operations = [ + migrations.RunPython(create_bitbucket_system_user), + ] diff --git a/taiga/hooks/bitbucket/migrations/__init__.py b/taiga/hooks/bitbucket/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/hooks/bitbucket/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/hooks/bitbucket/migrations/logo.png b/taiga/hooks/bitbucket/migrations/logo.png new file mode 100644 index 000000000..fbc456a76 Binary files /dev/null and b/taiga/hooks/bitbucket/migrations/logo.png differ diff --git a/taiga/hooks/bitbucket/models.py b/taiga/hooks/bitbucket/models.py new file mode 100644 index 000000000..93a3416b8 --- /dev/null +++ b/taiga/hooks/bitbucket/models.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# This file is needed to load migrations diff --git a/taiga/hooks/bitbucket/services.py b/taiga/hooks/bitbucket/services.py new file mode 100644 index 000000000..7e22c1306 --- /dev/null +++ b/taiga/hooks/bitbucket/services.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.conf import settings + +from taiga.base.utils.urls import get_absolute_url + + +# Set this in settings.PROJECT_MODULES_CONFIGURATORS["bitbucket"] +def get_or_generate_config(project): + config = project.modules_config.config + if config and "bitbucket" in config: + g_config = project.modules_config.config["bitbucket"] + else: + g_config = { + "secret": uuid.uuid4().hex, + "valid_origin_ips": settings.BITBUCKET_VALID_ORIGIN_IPS, + } + + url = reverse("bitbucket-hook-list") + url = get_absolute_url(url) + url = "%s?project=%s&key=%s" % (url, project.id, g_config["secret"]) + g_config["webhooks_url"] = url + return g_config + + +def get_bitbucket_user(user_id): + return get_user_model().objects.get(is_system=True, username__startswith="bitbucket") diff --git a/taiga/hooks/event_hooks.py b/taiga/hooks/event_hooks.py new file mode 100644 index 000000000..2dfc3d15f --- /dev/null +++ b/taiga/hooks/event_hooks.py @@ -0,0 +1,408 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import re + +from django.utils.translation import gettext as _ +from django.contrib.auth import get_user_model +from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus, EpicStatus, ProjectModulesConfig +from taiga.projects.epics.models import Epic +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.history.services import take_snapshot +from taiga.projects.notifications.services import send_notifications +from taiga.hooks.exceptions import ActionSyntaxException +from taiga.users.models import AuthData + +from taiga.base.utils import json + + +ISSUE_ACTION_CREATE = "ISSUE_CREATE" +ISSUE_ACTION_UPDATE = "ISSUE_UPDATE" +ISSUE_ACTION_DELETE = "ISSUE_DELETE" +ISSUE_ACTION_CLOSE = "ISSUE_CLOSE" +ISSUE_ACTION_REOPEN = "ISSUE_REOPEN" + + +class BaseEventHook: + platform = "Unknown" + platform_slug = "unknown" + + def __init__(self, project, payload): + self.project = project + self.payload = payload + + @property + def config(self): + if hasattr(self.project, "modules_config"): + return self.project.modules_config.config.get(self.platform_slug, {}) + return {} + + def ignore(self): + return False + + def get_user(self, user_id, platform=None): + user = None + + if user_id: + try: + user = AuthData.objects.get(key=platform, value=user_id).user + except AuthData.DoesNotExist: + pass + + if user is None and platform is not None: + user = get_user_model().objects.get(is_system=True, username__startswith=platform) + + return user + + +class BaseIssueCommentEventHook(BaseEventHook): + def get_data(self): + raise NotImplementedError + + def generate_issue_comment_message(self, **kwargs): + _issue_comment_message = _( + "[{user_name}]({user_url} " + "\"See {user_name}'s {platform} profile\") " + "says in [{platform}#{number}]({comment_url} \"Go to comment\"):\n\n" + "\"{comment_message}\"" + ) + _simple_issue_comment_message = _("Comment From {platform}:\n\n> {comment_message}") + try: + return _issue_comment_message.format(platform=self.platform, **kwargs) + except Exception: + return _simple_issue_comment_message.format(platform=self.platform, message=kwargs.get("comment_message")) + + def process_event(self): + if self.ignore(): + return + + data = self.get_data() + + if not all([data["comment_message"], data["url"]]): + raise ActionSyntaxException(_("Invalid issue comment information")) + + comment = self.generate_issue_comment_message(**data) + + issues = Issue.objects.filter(external_reference=[self.platform_slug, data["url"]]) + tasks = Task.objects.filter(external_reference=[self.platform_slug, data["url"]]) + uss = UserStory.objects.filter(external_reference=[self.platform_slug, data["url"]]) + + for item in list(issues) + list(tasks) + list(uss): + snapshot = take_snapshot(item, comment=comment, user=self.get_user(data["user_id"], self.platform_slug)) + send_notifications(item, history=snapshot) + + +class BaseIssueEventHook(BaseEventHook): + @property + def action_type(self): + raise NotImplementedError + + @property + def open_status(self): + return self.project.default_issue_status + + @property + def close_status(self): + close_status = self.config.get("close_status", None) + + if close_status: + try: + return self.project.issue_statuses.get(id=close_status) + except IssueStatus.DoesNotExist: + pass + + return self.project.issue_statuses.filter(is_closed=True).order_by("order").first() + + def get_data(self): + raise NotImplementedError + + def get_issue(self, data): + try: + return Issue.objects.get(project=self.project, + external_reference=[self.platform_slug, data["url"]]) + except Issue.DoesNotExist: + return None + + def generate_create_issue_comment(self, **kwargs): + _new_issue_message = _( + "Issue created by [{user_name}]({user_url} " + "\"See {user_name}'s {platform} profile\") " + "from [{platform}#{number}]({url} \"Go to issue\")." + ) + _simple_new_issue_message = _("Issue created from {platform}.") + try: + return _new_issue_message.format(platform=self.platform, **kwargs) + except Exception: + return _simple_new_issue_message.format(platform=self.platform) + + def generate_update_issue_comment(self, **kwargs): + _edit_issue_message = _( + "Issue modified by [{user_name}]({user_url} " + "\"See {user_name}'s {platform} profile\") " + "from [{platform}#{number}]({url} \"Go to issue\")." + ) + _simple_edit_issue_message = _("Issue modified from {platform}.") + try: + return _edit_issue_message.format(platform=self.platform, **kwargs) + except Exception: + return _simple_edit_issue_message.format(platform=self.platform) + + def generate_close_issue_comment(self, **kwargs): + _edit_issue_message = _( + "Issue closed by [{user_name}]({user_url} " + "\"See {user_name}'s {platform} profile\") " + "from [{platform}#{number}]({url} \"Go to issue\")." + ) + _simple_edit_issue_message = _("Issue closed from {platform}.") + try: + return _edit_issue_message.format(platform=self.platform, **kwargs) + except Exception: + return _simple_edit_issue_message.format(platform=self.platform) + + def generate_reopen_issue_comment(self, **kwargs): + _edit_issue_message = _( + "Issue reopened by [{user_name}]({user_url} " + "\"See {user_name}'s {platform} profile\") " + "from [{platform}#{number}]({url} \"Go to issue\")." + ) + _simple_edit_issue_message = _("Issue reopened from {platform}.") + try: + return _edit_issue_message.format(platform=self.platform, **kwargs) + except Exception: + return _simple_edit_issue_message.format(platform=self.platform) + + def _create_issue(self, data): + user = self.get_user(data["user_id"], self.platform_slug) + + issue = Issue.objects.create( + project=self.project, + subject=data["subject"], + description=data["description"], + status=data.get("status", self.project.default_issue_status), + type=self.project.default_issue_type, + severity=self.project.default_severity, + priority=self.project.default_priority, + external_reference=[self.platform_slug, data['url']], + owner=user + ) + take_snapshot(issue, user=user) + + comment = self.generate_create_issue_comment(**data) + + snapshot = take_snapshot(issue, comment=comment, user=user) + send_notifications(issue, history=snapshot) + + def _update_issue(self, data): + issue = self.get_issue(data) + + if not issue: + # The issue is not created yet, add it + return self._create_issue(data) + + user = self.get_user(data["user_id"], self.platform_slug) + + issue.subject = data["subject"] + issue.description = data["description"] + issue.save() + + comment = self.generate_update_issue_comment(**data) + + snapshot = take_snapshot(issue, comment=comment, user=user) + send_notifications(issue, history=snapshot) + + def _close_issue(self, data): + issue = self.get_issue(data) + + if not issue: + # The issue is not created yet, add it + return self._create_issue(data) + + if not self.close_status: + return + + user = self.get_user(data["user_id"], self.platform_slug) + + issue.status = self.close_status + issue.save() + + comment = self.generate_close_issue_comment(**data) + + snapshot = take_snapshot(issue, comment=comment, user=user) + send_notifications(issue, history=snapshot) + + def _reopen_issue(self, data): + issue = self.get_issue(data) + + if not issue: + # The issue is not created yet, add it + return self._create_issue(data) + + if not self.open_status: + return + + user = self.get_user(data["user_id"], self.platform_slug) + + issue.status = self.open_status + issue.save() + + comment = self.generate_reopen_issue_comment(**data) + + snapshot = take_snapshot(issue, comment=comment, user=user) + send_notifications(issue, history=snapshot) + + def _delete_issue(self, data): + raise NotImplementedError + + def process_event(self): + if self.ignore(): + return + + data = self.get_data() + + if not all([data["subject"], data["url"]]): + raise ActionSyntaxException(_("Invalid issue information")) + + if self.action_type == ISSUE_ACTION_CREATE: + self._create_issue(data) + elif self.action_type == ISSUE_ACTION_UPDATE: + self._update_issue(data) + elif self.action_type == ISSUE_ACTION_CLOSE: + self._close_issue(data) + elif self.action_type == ISSUE_ACTION_REOPEN: + self._reopen_issue(data) + elif self.action_type == ISSUE_ACTION_DELETE: + self._delete_issue(data) + else: + raise NotImplementedError + + +class BasePushEventHook(BaseEventHook): + def get_data(self): + raise NotImplementedError + + def generate_status_change_comment(self, **kwargs): + if kwargs.get("user_url", None) is None: + user_text = kwargs.get("user_name", _("unknown user")) + else: + user_text = "[{user_name}]({user_url} \"See {user_name}'s {platform} profile\")".format( + platform=self.platform, + **kwargs + ) + _status_change_message = _( + "{user_text} changed the status from " + "[{platform} commit]({commit_url} \"See commit '{commit_id} - {commit_short_message}'\")\n\n" + " - Status: **{src_status}** → **{dst_status}**" + ) + _simple_status_change_message = _( + "Changed status from {platform} commit.\n\n" + " - Status: **{src_status}** → **{dst_status}**" + ) + try: + return _status_change_message.format(platform=self.platform, user_text=user_text, **kwargs) + except Exception: + return _simple_status_change_message.format(platform=self.platform) + + def generate_commit_reference_comment(self, **kwargs): + if kwargs.get("user_url", None) is None: + user_text = kwargs.get("user_name", _("unknown user")) + else: + user_text = "[{user_name}]({user_url} \"See {user_name}'s {platform} profile\")".format( + platform=self.platform, + **kwargs + ) + + _status_change_message = _( + "This {type_name} has been mentioned by {user_text} " + "in the [{platform} commit]({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " + "\"{commit_message}\"" + ) + _simple_status_change_message = _( + "This issue has been mentioned in the {platform} commit " + "\"{commit_message}\"" + ) + try: + return _status_change_message.format(platform=self.platform, user_text=user_text, **kwargs) + except Exception: + return _simple_status_change_message.format(platform=self.platform) + + def get_item_classes(self, ref): + if Epic.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Epic + statusClass = EpicStatus + elif Issue.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Issue + statusClass = IssueStatus + elif Task.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Task + statusClass = TaskStatus + elif UserStory.objects.filter(project=self.project, ref=ref).exists(): + modelClass = UserStory + statusClass = UserStoryStatus + else: + raise ActionSyntaxException(_("The referenced element doesn't exist")) + + return (modelClass, statusClass) + + def get_item_by_ref(self, ref): + (modelClass, statusClass) = self.get_item_classes(ref) + + return modelClass.objects.get(project=self.project, ref=ref) + + def set_item_status(self, ref, status_slug): + (modelClass, statusClass) = self.get_item_classes(ref) + element = modelClass.objects.get(project=self.project, ref=ref) + + try: + status = statusClass.objects.get(project=self.project, slug=status_slug) + except statusClass.DoesNotExist: + raise ActionSyntaxException(_("The status doesn't exist")) + + src_status = element.status.name + dst_status = status.name + + element.status = status + element.save() + return (element, src_status, dst_status) + + def process_event(self): + if self.ignore(): + return + data = self.get_data() + + for commit in data: + consumed_refs = [] + + # Status changes + p = re.compile(r"tg-(\d+) +#([-\w]+)") + for m in p.finditer(commit['commit_message'].lower()): + ref = m.group(1) + status_slug = m.group(2) + (element, src_status, dst_status) = self.set_item_status(ref, status_slug) + + comment = self.generate_status_change_comment(src_status=src_status, dst_status=dst_status, **commit) + snapshot = take_snapshot(element, + comment=comment, + user=self.get_user(commit["user_id"], self.platform_slug)) + send_notifications(element, history=snapshot) + consumed_refs.append(ref) + + # Reference on commit + p = re.compile(r"tg-(\d+)") + for m in p.finditer(commit['commit_message'].lower()): + ref = m.group(1) + if ref in consumed_refs: + continue + element = self.get_item_by_ref(ref) + type_name = element.__class__._meta.verbose_name + comment = self.generate_commit_reference_comment(type_name=type_name, **commit) + snapshot = take_snapshot(element, + comment=comment, + user=self.get_user(commit['user_id'], self.platform_slug)) + send_notifications(element, history=snapshot) + consumed_refs.append(ref) diff --git a/taiga/hooks/exceptions.py b/taiga/hooks/exceptions.py new file mode 100644 index 000000000..eef0791de --- /dev/null +++ b/taiga/hooks/exceptions.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +class ActionSyntaxException(Exception): + pass diff --git a/taiga/hooks/github/__init__.py b/taiga/hooks/github/__init__.py new file mode 100644 index 000000000..e6281a1af --- /dev/null +++ b/taiga/hooks/github/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + diff --git a/taiga/hooks/github/api.py b/taiga/hooks/github/api.py new file mode 100644 index 000000000..c8b01a626 --- /dev/null +++ b/taiga/hooks/github/api.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks + +import hmac +import hashlib + + +class GitHubViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook, + "issues": event_hooks.IssuesEventHook, + "issue_comment": event_hooks.IssueCommentEventHook, + } + + def _validate_signature(self, project, request): + x_hub_signature = request.headers.get("x-hub-signature", None) + if not x_hub_signature: + return False + + sha_name, signature = x_hub_signature.split('=') + if sha_name != 'sha1': + return False + + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + secret = project.modules_config.config.get("github", {}).get("secret", "") + secret = bytes(secret.encode("utf-8")) + mac = hmac.new(secret, msg=request.body, digestmod=hashlib.sha1) + return hmac.compare_digest(mac.hexdigest(), signature) + + def _get_event_name(self, request): + return request.headers.get("x-github-event", None) diff --git a/taiga/hooks/github/apps.py b/taiga/hooks/github/apps.py new file mode 100644 index 000000000..113ec3070 --- /dev/null +++ b/taiga/hooks/github/apps.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig +from django.apps import apps + +from taiga.hooks.github.signals import handle_move_on_destroy_issue_status + + +def _connect_all_signals(): + from taiga.projects.signals import issue_status_post_move_on_destroy as issue_status_post_move_on_destroy_signal + + issue_status_post_move_on_destroy_signal.connect(handle_move_on_destroy_issue_status, + sender=apps.get_model("projects", "IssueStatus"), + dispatch_uid="move_on_destroy_issue_status_on_github") + +def _disconnect_all_signals(): + from taiga.projects.signals import issue_status_post_move_on_destroy as issue_status_post_move_on_destroy_signal + + issue_status_post_move_on_destroy_signal.disconnect(sender=apps.get_model("projects", "IssueStatus"), + dispatch_uid="move_on_destroy_issue_status_on_github") + + +class GithubHooksAppConfig(AppConfig): + name = "taiga.hooks.github" + verbose_name = "GithubHooks" + watched_types = [] + + def ready(self): + _connect_all_signals() diff --git a/taiga/hooks/github/event_hooks.py b/taiga/hooks/github/event_hooks.py new file mode 100644 index 000000000..505acdfff --- /dev/null +++ b/taiga/hooks/github/event_hooks.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import re + +from taiga.hooks.event_hooks import (BaseIssueEventHook, BaseIssueCommentEventHook, BasePushEventHook, + ISSUE_ACTION_CREATE, ISSUE_ACTION_UPDATE, ISSUE_ACTION_CLOSE, + ISSUE_ACTION_REOPEN) + + +class BaseGitHubEventHook(): + platform = "GitHub" + platform_slug = "github" + + def replace_github_references(self, project_url, wiki_text): + if wiki_text is None: + wiki_text = "" + + template = fr"\g<1>[GitHub#\g<2>]({project_url}/issues/\g<2>)\g<3>" + return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M) + + +class IssuesEventHook(BaseGitHubEventHook, BaseIssueEventHook): + _ISSUE_ACTIONS = { + "opened": ISSUE_ACTION_CREATE, + "edited": ISSUE_ACTION_UPDATE, + "closed": ISSUE_ACTION_CLOSE, + "reopened": ISSUE_ACTION_REOPEN, + } + + @property + def action_type(self): + _action = self.payload.get('action', '') + return self._ISSUE_ACTIONS.get(_action, None) + + def ignore(self): + return self.action_type not in [ + ISSUE_ACTION_CREATE, + ISSUE_ACTION_UPDATE, + ISSUE_ACTION_CLOSE, + ISSUE_ACTION_REOPEN, + ] + + def get_data(self): + description = self.payload.get('issue', {}).get('body', None) + project_url = self.payload.get('repository', {}).get('html_url', None) + state = self.payload.get('issue', {}).get('state', 'open') + + return { + "number": self.payload.get('issue', {}).get('number', None), + "subject": self.payload.get('issue', {}).get('title', None), + "url": self.payload.get('issue', {}).get('html_url', None), + "user_id": self.payload.get('sender', {}).get('id', None), + "user_name": self.payload.get('sender', {}).get('login', None), + "user_url": self.payload.get('sender', {}).get('html_url', None), + "description": self.replace_github_references(project_url, description), + "status": self.close_status if state == "closed" else self.open_status, + } + + +class IssueCommentEventHook(BaseGitHubEventHook, BaseIssueCommentEventHook): + def ignore(self): + return self.payload.get('action', None) != "created" + + def get_data(self): + comment_message = self.payload.get('comment', {}).get('body', None) + project_url = self.payload.get('repository', {}).get('html_url', None) + return { + "number": self.payload.get('issue', {}).get('number', None), + "url": self.payload.get('issue', {}).get('html_url', None), + "user_id": self.payload.get('sender', {}).get('id', None), + "user_name": self.payload.get('sender', {}).get('login', None), + "user_url": self.payload.get('sender', {}).get('html_url', None), + "comment_url": self.payload.get('comment', {}).get('html_url', None), + "comment_message": self.replace_github_references(project_url, comment_message), + } + + +class PushEventHook(BaseGitHubEventHook, BasePushEventHook): + def get_data(self): + result = [] + github_user = self.payload.get('sender', {}) + commits = self.payload.get("commits", []) + for commit in filter(None, commits): + result.append({ + "user_id": github_user.get('id', None), + "user_name": github_user.get('login', None), + "user_url": github_user.get('html_url', None), + "commit_id": commit.get("id", None), + "commit_url": commit.get("url", None), + "commit_message": commit.get("message").strip(), + "commit_short_message": commit.get("message").split("\n")[0].strip(), + }) + + return result diff --git a/taiga/hooks/github/migrations/0001_initial.py b/taiga/hooks/github/migrations/0001_initial.py new file mode 100644 index 000000000..3fb569ba6 --- /dev/null +++ b/taiga/hooks/github/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals +import os.path +import uuid + +from django.conf import settings +from django.core.files import File +from django.db import migrations + + +def create_github_system_user(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version + User = apps.get_model("users", "User") + db_alias = schema_editor.connection.alias + random_hash = uuid.uuid4().hex + user = User.objects.using(db_alias).create( + username="github-{}".format(random_hash), + email="github-{}@taiga.io".format(random_hash), + full_name="GitHub", + is_active=False, + is_system=True, + bio="", + ) + f = open(os.path.join(settings.BASE_DIR, "taiga/hooks/github/migrations/logo.png"), "rb") + user.photo.save("logo.png", File(f)) + user.save() + f.close() + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_auto_20141030_1132') + ] + + operations = [ + migrations.RunPython(create_github_system_user), + ] diff --git a/taiga/hooks/github/migrations/__init__.py b/taiga/hooks/github/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/hooks/github/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/hooks/github/migrations/logo.png b/taiga/hooks/github/migrations/logo.png new file mode 100644 index 000000000..42f4046ef Binary files /dev/null and b/taiga/hooks/github/migrations/logo.png differ diff --git a/taiga/hooks/github/models.py b/taiga/hooks/github/models.py new file mode 100644 index 000000000..93a3416b8 --- /dev/null +++ b/taiga/hooks/github/models.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# This file is needed to load migrations diff --git a/taiga/hooks/github/services.py b/taiga/hooks/github/services.py new file mode 100644 index 000000000..99cb01ba6 --- /dev/null +++ b/taiga/hooks/github/services.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.urls import reverse + +from taiga.base.utils.urls import get_absolute_url + + +# Set this in settings.PROJECT_MODULES_CONFIGURATORS["github"] +def get_or_generate_config(project): + # Default config + config = { + "secret": uuid.uuid4().hex + } + + close_status = project.issue_statuses.filter(is_closed=True).order_by("order").first() + if close_status: + config["close_status"] = close_status.id + + # Update with current config if exist + if project.modules_config.config: + config.update(project.modules_config.config.get("github", {})) + + # Generate webhook url + url = reverse("github-hook-list") + url = get_absolute_url(url) + url = "%s?project=%s" % (url, project.id) + config["webhooks_url"] = url + return config diff --git a/taiga/hooks/github/signals.py b/taiga/hooks/github/signals.py new file mode 100644 index 000000000..9d9654523 --- /dev/null +++ b/taiga/hooks/github/signals.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +def handle_move_on_destroy_issue_status(sender, deleted, moved, **kwargs): + if not hasattr(deleted.project, "modules_config"): + return + + modules_config = deleted.project.modules_config + + if modules_config.config and modules_config.config.get("github", {}): + current_status_id = modules_config.config.get("github", {}).get("close_status", None) + + if current_status_id and current_status_id == deleted.id: + modules_config.config["github"]["close_status"] = moved.id + modules_config.save() diff --git a/taiga/hooks/gitlab/__init__.py b/taiga/hooks/gitlab/__init__.py new file mode 100644 index 000000000..e6281a1af --- /dev/null +++ b/taiga/hooks/gitlab/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + diff --git a/taiga/hooks/gitlab/api.py b/taiga/hooks/gitlab/api.py new file mode 100644 index 000000000..66f983091 --- /dev/null +++ b/taiga/hooks/gitlab/api.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings + +from ipware.ip import get_ip + +from taiga.base.utils import json + +from taiga.projects.models import Project +from taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks + +from netaddr import all_matching_cidrs +from netaddr.core import AddrFormatError + +class GitLabViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook, + "issue": event_hooks.IssuesEventHook, + "note": event_hooks.IssueCommentEventHook, + } + + def _validate_signature(self, project, request): + secret_key = request.GET.get("key", None) + + if secret_key is None: + return False + + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + gitlab_config = project.modules_config.config.get("gitlab", {}) + + project_secret = gitlab_config.get("secret", "") + if not project_secret: + return False + + valid_origin_ips = gitlab_config.get("valid_origin_ips", settings.GITLAB_VALID_ORIGIN_IPS) + origin_ip = get_ip(request) + matching_origin_ip = True + + if valid_origin_ips: + try: + matching_origin_ip = len(all_matching_cidrs(origin_ip,valid_origin_ips)) > 0 + + except (AddrFormatError, ValueError): + matching_origin_ip = False + + if not matching_origin_ip: + return False + + return project_secret == secret_key + + def _get_event_name(self, request): + payload = json.loads(request.body.decode("utf-8")) + return payload.get('object_kind', 'push') if payload is not None else 'empty' diff --git a/taiga/hooks/gitlab/apps.py b/taiga/hooks/gitlab/apps.py new file mode 100644 index 000000000..676b673ef --- /dev/null +++ b/taiga/hooks/gitlab/apps.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig +from django.apps import apps + +from taiga.hooks.gitlab.signals import handle_move_on_destroy_issue_status + + +def _connect_all_signals(): + from taiga.projects.signals import issue_status_post_move_on_destroy as issue_status_post_move_on_destroy_signal + + issue_status_post_move_on_destroy_signal.connect(handle_move_on_destroy_issue_status, + sender=apps.get_model("projects", "IssueStatus"), + dispatch_uid="move_on_destroy_issue_status_on_gitlab") + +def _disconnect_all_signals(): + from taiga.projects.signals import issue_status_post_move_on_destroy as issue_status_post_move_on_destroy_signal + + issue_status_post_move_on_destroy_signal.disconnect(sender=apps.get_model("projects", "IssueStatus"), + dispatch_uid="move_on_destroy_issue_status_on_gitlab") + + +class GitLabHooksAppConfig(AppConfig): + name = "taiga.hooks.gitlab" + verbose_name = "GitLabHooks" + watched_types = [] + + def ready(self): + _connect_all_signals() diff --git a/taiga/hooks/gitlab/event_hooks.py b/taiga/hooks/gitlab/event_hooks.py new file mode 100644 index 000000000..77bdd6bf5 --- /dev/null +++ b/taiga/hooks/gitlab/event_hooks.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import re +import os + +from taiga.hooks.event_hooks import (BaseIssueEventHook, BaseIssueCommentEventHook, BasePushEventHook, + ISSUE_ACTION_CREATE, ISSUE_ACTION_UPDATE, ISSUE_ACTION_CLOSE, + ISSUE_ACTION_REOPEN) + + +class BaseGitLabEventHook(): + platform = "GitLab" + platform_slug = "gitlab" + + def replace_gitlab_references(self, project_url, wiki_text): + if wiki_text is None: + wiki_text = "" + + template = fr"\g<1>[GitLab#\g<2>]({project_url}/issues/\g<2>)\g<3>" + return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M) + + +class IssuesEventHook(BaseGitLabEventHook, BaseIssueEventHook): + _ISSUE_ACTIONS = { + "open": ISSUE_ACTION_CREATE, + "update": ISSUE_ACTION_UPDATE, + "close": ISSUE_ACTION_CLOSE, + "reopen": ISSUE_ACTION_REOPEN, + } + + @property + def action_type(self): + _action = self.payload.get('object_attributes', {}).get("action", "") + return self._ISSUE_ACTIONS.get(_action, None) + + def ignore(self): + return self.action_type not in [ + ISSUE_ACTION_CREATE, + ISSUE_ACTION_UPDATE, + ISSUE_ACTION_CLOSE, + ISSUE_ACTION_REOPEN, + ] + + def get_data(self): + description = self.payload.get('object_attributes', {}).get('description', None) + project_url = self.payload.get('repository', {}).get('homepage', "") + user_name = self.payload.get('user', {}).get('username', None) + state = self.payload.get('object_attributes', {}).get('state', 'opened') + + return { + "number": self.payload.get('object_attributes', {}).get('iid', None), + "subject": self.payload.get('object_attributes', {}).get('title', None), + "url": self.payload.get('object_attributes', {}).get('url', None), + "user_id": None, + "user_name": user_name, + "user_url": os.path.join(os.path.dirname(os.path.dirname(project_url)), user_name), + "description": self.replace_gitlab_references(project_url, description), + "status": self.close_status if state == "closed" else self.open_status, + } + + +class IssueCommentEventHook(BaseGitLabEventHook, BaseIssueCommentEventHook): + def ignore(self): + return self.payload.get('object_attributes', {}).get("noteable_type", None) != "Issue" + + def get_data(self): + comment_message = self.payload.get('object_attributes', {}).get('note', None) + project_url = self.payload.get('repository', {}).get('homepage', "") + issue_url = self.payload.get('issue', {}).get('url', None) + number = self.payload.get('issue', {}).get('iid', None) + user_name = self.payload.get('user', {}).get('username', None) + return { + "number": number, + "url": issue_url, + "user_id": None, + "user_name": user_name, + "user_url": os.path.join(os.path.dirname(os.path.dirname(project_url)), user_name), + "comment_url": self.payload.get('object_attributes', {}).get('url', None), + "comment_message": self.replace_gitlab_references(project_url, comment_message), + } + + +class PushEventHook(BaseGitLabEventHook, BasePushEventHook): + def get_data(self): + result = [] + for commit in self.payload.get("commits", []): + user_name = commit.get('author', {}).get('name', None) + result.append({ + "user_id": None, + "user_name": user_name, + "user_url": None, + "commit_id": commit.get("id", None), + "commit_url": commit.get("url", None), + "commit_message": commit.get("message").strip(), + "commit_short_message": commit.get("message").split("\n")[0].strip(), + }) + return result diff --git a/taiga/hooks/gitlab/migrations/0001_initial.py b/taiga/hooks/gitlab/migrations/0001_initial.py new file mode 100644 index 000000000..46984873d --- /dev/null +++ b/taiga/hooks/gitlab/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals +import os.path +import uuid + +from django.conf import settings +from django.core.files import File +from django.db import migrations + + +def create_gitlab_system_user(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version + User = apps.get_model("users", "User") + db_alias = schema_editor.connection.alias + random_hash = uuid.uuid4().hex + user = User.objects.using(db_alias).create( + username="gitlab-{}".format(random_hash), + email="gitlab-{}@taiga.io".format(random_hash), + full_name="GitLab", + is_active=False, + is_system=True, + bio="", + ) + f = open(os.path.join(settings.BASE_DIR, "taiga/hooks/gitlab/migrations/logo.png"), "rb") + user.photo.save("logo.png", File(f)) + user.save() + f.close() + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0011_user_theme') + ] + + operations = [ + migrations.RunPython(create_gitlab_system_user), + ] diff --git a/taiga/hooks/gitlab/migrations/0002_auto_20150703_1102.py b/taiga/hooks/gitlab/migrations/0002_auto_20150703_1102.py new file mode 100644 index 000000000..b0430ee8e --- /dev/null +++ b/taiga/hooks/gitlab/migrations/0002_auto_20150703_1102.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals +import os.path + +from django.conf import settings +from django.db import models, migrations +from django.core.files import File + + +def update_gitlab_system_user_photo_to_v2(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version + User = apps.get_model("users", "User") + db_alias = schema_editor.connection.alias + + try: + user = User.objects.using(db_alias).get(username__startswith="gitlab-", + is_active=False, + is_system=True) + f = open(os.path.join(settings.BASE_DIR, "taiga/hooks/gitlab/migrations/logo-v2.png"), "rb") + user.photo.save("logo.png", File(f)) + user.save() + f.close() + except User.DoesNotExist: + pass + +def update_gitlab_system_user_photo_to_v1(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version + User = apps.get_model("users", "User") + db_alias = schema_editor.connection.alias + + try: + user = User.objects.using(db_alias).get(username__startswith="gitlab-", + is_active=False, + is_system=True) + f = open(os.path.join(settings.BASE_DIR, "taiga/hooks/gitlab/migrations/logo.png"), "rb") + user.photo.save("logo.png", File(f)) + user.save() + f.close() + except User.DoesNotExist: + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('gitlab', '0001_initial'), + ('users', '0011_user_theme'), + ] + + operations = [ + migrations.RunPython(update_gitlab_system_user_photo_to_v2, + update_gitlab_system_user_photo_to_v1), + ] diff --git a/taiga/hooks/gitlab/migrations/__init__.py b/taiga/hooks/gitlab/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/hooks/gitlab/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/hooks/gitlab/migrations/logo-v2.png b/taiga/hooks/gitlab/migrations/logo-v2.png new file mode 100644 index 000000000..01063fc38 Binary files /dev/null and b/taiga/hooks/gitlab/migrations/logo-v2.png differ diff --git a/taiga/hooks/gitlab/migrations/logo.png b/taiga/hooks/gitlab/migrations/logo.png new file mode 100644 index 000000000..bd90452af Binary files /dev/null and b/taiga/hooks/gitlab/migrations/logo.png differ diff --git a/taiga/hooks/gitlab/models.py b/taiga/hooks/gitlab/models.py new file mode 100644 index 000000000..93a3416b8 --- /dev/null +++ b/taiga/hooks/gitlab/models.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# This file is needed to load migrations diff --git a/taiga/hooks/gitlab/services.py b/taiga/hooks/gitlab/services.py new file mode 100644 index 000000000..36916c515 --- /dev/null +++ b/taiga/hooks/gitlab/services.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.urls import reverse +from django.conf import settings + +from taiga.base.utils.urls import get_absolute_url + + +# Set this in settings.PROJECT_MODULES_CONFIGURATORS["gitlab"] +def get_or_generate_config(project): + # Default config + config = { + "secret": uuid.uuid4().hex, + "valid_origin_ips": settings.GITLAB_VALID_ORIGIN_IPS, + } + + close_status = project.issue_statuses.filter(is_closed=True).order_by("order").first() + if close_status: + config["close_status"] = close_status.id + + # Update with current config if exist + if project.modules_config.config: + config.update(project.modules_config.config.get("gitlab", {})) + + # Generate webhook url + url = reverse("gitlab-hook-list") + url = get_absolute_url(url) + url = "{}?project={}&key={}".format(url, project.id, config["secret"]) + config["webhooks_url"] = url + + return config diff --git a/taiga/hooks/gitlab/signals.py b/taiga/hooks/gitlab/signals.py new file mode 100644 index 000000000..daf2394de --- /dev/null +++ b/taiga/hooks/gitlab/signals.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +def handle_move_on_destroy_issue_status(sender, deleted, moved, **kwargs): + if not hasattr(deleted.project, "modules_config"): + return + + modules_config = deleted.project.modules_config + + if modules_config.config and modules_config.config.get("gitlab", {}): + current_status_id = modules_config.config.get("gitlab", {}).get("close_status", None) + + if current_status_id and current_status_id == deleted.id: + modules_config.config["gitlab"]["close_status"] = moved.id + modules_config.save() diff --git a/taiga/hooks/gogs/__init__.py b/taiga/hooks/gogs/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/hooks/gogs/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/hooks/gogs/api.py b/taiga/hooks/gogs/api.py new file mode 100644 index 000000000..a5e3f53ef --- /dev/null +++ b/taiga/hooks/gogs/api.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks + +import hmac +import hashlib + + +class GogsViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook + } + + def _validate_signature(self, project, request): + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + secret = project.modules_config.config.get("gogs", {}).get("secret", None) + if secret is None: + return False + + signature = request.headers.get("x-gogs-signature", None) + if not signature: # Old format signature support (before 0.11 version) + payload = self._get_payload(request) + return payload.get('secret', None) == secret + + secret = project.modules_config.config.get("gogs", {}).get("secret", "") + secret = bytes(secret.encode("utf-8")) + mac = hmac.new(secret, msg=request.body, digestmod=hashlib.sha256) + return hmac.compare_digest(mac.hexdigest(), signature) + + def _get_event_name(self, request): + return "push" diff --git a/taiga/hooks/gogs/event_hooks.py b/taiga/hooks/gogs/event_hooks.py new file mode 100644 index 000000000..43037d509 --- /dev/null +++ b/taiga/hooks/gogs/event_hooks.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import re +import os.path + +from taiga.hooks.event_hooks import BasePushEventHook + + +class BaseGogsEventHook(): + platform = "Gogs" + platform_slug = "gogs" + + def replace_gogs_references(self, project_url, wiki_text): + if wiki_text is None: + wiki_text = "" + + template = fr"\g<1>[Gogs#\g<2>]({project_url}/issues/\g<2>)\g<3>" + return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M) + + +class PushEventHook(BaseGogsEventHook, BasePushEventHook): + def get_data(self): + result = [] + commits = self.payload.get("commits", []) + project_url = self.payload.get("repository", {}).get("html_url", "") + + for commit in filter(None, commits): + user_name = commit.get('author', {}).get('username', "") + result.append({ + "user_id": user_name, + "user_name": user_name, + "user_url": os.path.join(os.path.dirname(os.path.dirname(project_url)), user_name), + "commit_id": commit.get("id", None), + "commit_url": commit.get("url", None), + "commit_message": commit.get("message").strip(), + "commit_short_message": commit.get("message").split("\n")[0].strip(), + }) + return result diff --git a/taiga/hooks/gogs/migrations/0001_initial.py b/taiga/hooks/gogs/migrations/0001_initial.py new file mode 100644 index 000000000..5d1742980 --- /dev/null +++ b/taiga/hooks/gogs/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.core.files import File + +import uuid +import os + +CUR_DIR = os.path.dirname(__file__) + + +def create_gogs_system_user(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version + User = apps.get_model("users", "User") + db_alias = schema_editor.connection.alias + + if not User.objects.using(db_alias).filter(is_system=True, username__startswith="gogs-").exists(): + random_hash = uuid.uuid4().hex + user = User.objects.using(db_alias).create( + username="gogs-{}".format(random_hash), + email="gogs-{}@taiga.io".format(random_hash), + full_name="Gogs", + is_active=False, + is_system=True, + bio="", + ) + f = open("{}/logo.png".format(CUR_DIR), "rb") + user.photo.save("logo.png", File(f)) + user.save() + f.close() + + +class Migration(migrations.Migration): + dependencies = [ + ('users', '0010_auto_20150414_0936') + ] + + operations = [ + migrations.RunPython(create_gogs_system_user), + ] diff --git a/taiga/hooks/gogs/migrations/__init__.py b/taiga/hooks/gogs/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/hooks/gogs/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/hooks/gogs/migrations/logo.png b/taiga/hooks/gogs/migrations/logo.png new file mode 100644 index 000000000..343855355 Binary files /dev/null and b/taiga/hooks/gogs/migrations/logo.png differ diff --git a/taiga/hooks/gogs/models.py b/taiga/hooks/gogs/models.py new file mode 100644 index 000000000..93a3416b8 --- /dev/null +++ b/taiga/hooks/gogs/models.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# This file is needed to load migrations diff --git a/taiga/hooks/gogs/services.py b/taiga/hooks/gogs/services.py new file mode 100644 index 000000000..798eb056b --- /dev/null +++ b/taiga/hooks/gogs/services.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.urls import reverse + +from taiga.base.utils.urls import get_absolute_url + + +# Set this in settings.PROJECT_MODULES_CONFIGURATORS["gogs"] +def get_or_generate_config(project): + config = project.modules_config.config + if config and "gogs" in config: + g_config = project.modules_config.config["gogs"] + else: + g_config = {"secret": uuid.uuid4().hex} + + url = reverse("gogs-hook-list") + url = get_absolute_url(url) + url = "%s?project=%s" % (url, project.id) + g_config["webhooks_url"] = url + return g_config diff --git a/taiga/importers/__init__.py b/taiga/importers/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/importers/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/importers/api.py b/taiga/importers/api.py new file mode 100644 index 000000000..45913fafc --- /dev/null +++ b/taiga/importers/api.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import viewsets +from taiga.base.decorators import list_route + + +class BaseImporterViewSet(viewsets.ViewSet): + @list_route(methods=["GET"]) + def list_users(self, request, *args, **kwargs): + raise NotImplementedError + + @list_route(methods=["GET"]) + def list_projects(self, request, *args, **kwargs): + raise NotImplementedError + + @list_route(methods=["POST"]) + def import_project(self, request, *args, **kwargs): + raise NotImplementedError + + @list_route(methods=["GET"]) + def auth_url(self, request, *args, **kwargs): + raise NotImplementedError + + @list_route(methods=["POST"]) + def authorize(self, request, *args, **kwargs): + raise NotImplementedError diff --git a/taiga/importers/asana/api.py b/taiga/importers/asana/api.py new file mode 100644 index 000000000..5530b519f --- /dev/null +++ b/taiga/importers/asana/api.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ +from django.conf import settings + +from taiga.base.api import viewsets +from taiga.base import response +from taiga.base import exceptions as exc +from taiga.base.decorators import list_route +from taiga.users.services import get_user_photo_url +from taiga.users.gravatar import get_user_gravatar_id + +from taiga.importers import permissions, exceptions +from taiga.importers.services import resolve_users_bindings +from .importer import AsanaImporter +from . import tasks + + +class AsanaImporterViewSet(viewsets.ViewSet): + permission_classes = (permissions.ImporterPermission,) + + @list_route(methods=["POST"]) + def list_users(self, request, *args, **kwargs): + self.check_permissions(request, "list_users", None) + + token = request.DATA.get('token', None) + project_id = request.DATA.get('project', None) + + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + importer = AsanaImporter(request.user, token) + + try: + users = importer.list_users(project_id) + except exceptions.InvalidRequest: + raise exc.BadRequest(_('Invalid Asana API request')) + except exceptions.FailedRequest: + raise exc.BadRequest(_('Failed to make the request to Asana API')) + + for user in users: + if user['detected_user']: + user['user'] = { + 'id': user['detected_user'].id, + 'full_name': user['detected_user'].get_full_name(), + 'gravatar_id': get_user_gravatar_id(user['detected_user']), + 'photo': get_user_photo_url(user['detected_user']), + } + del(user['detected_user']) + return response.Ok(users) + + @list_route(methods=["POST"]) + def list_projects(self, request, *args, **kwargs): + self.check_permissions(request, "list_projects", None) + token = request.DATA.get('token', None) + importer = AsanaImporter(request.user, token) + try: + projects = importer.list_projects() + except exceptions.InvalidRequest: + raise exc.BadRequest(_('Invalid Asana API request')) + except exceptions.FailedRequest: + raise exc.BadRequest(_('Failed to make the request to Asana API')) + return response.Ok(projects) + + @list_route(methods=["POST"]) + def import_project(self, request, *args, **kwargs): + self.check_permissions(request, "import_project", None) + + token = request.DATA.get('token', None) + project_id = request.DATA.get('project', None) + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + options = { + "name": request.DATA.get('name', None), + "description": request.DATA.get('description', None), + "template": request.DATA.get('template', "scrum"), + "users_bindings": resolve_users_bindings(request.DATA.get("users_bindings", {})), + "keep_external_reference": request.DATA.get("keep_external_reference", False), + "is_private": request.DATA.get("is_private", False), + } + + if settings.CELERY_ENABLED: + task = tasks.import_project.delay(request.user.id, token, project_id, options) + return response.Accepted({"task_id": task.id}) + + importer = AsanaImporter(request.user, token) + project = importer.import_project(project_id, options) + project_data = { + "slug": project.slug, + "my_permissions": ["view_us"], + "is_backlog_activated": project.is_backlog_activated, + "is_kanban_activated": project.is_kanban_activated, + } + + return response.Ok(project_data) + + @list_route(methods=["GET"]) + def auth_url(self, request, *args, **kwargs): + self.check_permissions(request, "auth_url", None) + + url = AsanaImporter.get_auth_url( + settings.IMPORTERS.get('asana', {}).get('app_id', None), + settings.IMPORTERS.get('asana', {}).get('app_secret', None), + settings.IMPORTERS.get('asana', {}).get('callback_url', None) + ) + + return response.Ok({"url": url}) + + @list_route(methods=["POST"]) + def authorize(self, request, *args, **kwargs): + self.check_permissions(request, "authorize", None) + + code = request.DATA.get('code', None) + if code is None: + raise exc.BadRequest(_("Code param needed")) + + try: + asana_token = AsanaImporter.get_access_token( + code, + settings.IMPORTERS.get('asana', {}).get('app_id', None), + settings.IMPORTERS.get('asana', {}).get('app_secret', None), + settings.IMPORTERS.get('asana', {}).get('callback_url', None) + ) + except exceptions.InvalidRequest: + raise exc.BadRequest(_('Invalid Asana API request')) + except exceptions.FailedRequest: + raise exc.BadRequest(_('Failed to make the request to Asana API')) + + return response.Ok({"token": asana_token}) diff --git a/taiga/importers/asana/importer.py b/taiga/importers/asana/importer.py new file mode 100644 index 000000000..18163a569 --- /dev/null +++ b/taiga/importers/asana/importer.py @@ -0,0 +1,359 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import requests +import asana +from django.core.files.base import ContentFile +from django.contrib.contenttypes.models import ContentType + +from taiga.projects.models import Project, ProjectTemplate +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task +from taiga.projects.attachments.models import Attachment +from taiga.projects.history.services import take_snapshot +from taiga.projects.history.models import HistoryEntry +from taiga.projects.custom_attributes.models import UserStoryCustomAttribute, TaskCustomAttribute +from taiga.users.models import User +from taiga.timeline.rebuilder import rebuild_timeline +from taiga.timeline.models import Timeline +from taiga.importers import exceptions +from taiga.importers import services as import_service + + +class AsanaClient(asana.Client): + def request(self, method, path, **options): + try: + return super().request(method, path, **options) + except asana.error.AsanaError: + raise exceptions.InvalidRequest() + except Exception as e: + raise exceptions.FailedRequest() + + +class AsanaImporter: + def __init__(self, user, token, import_closed_data=False): + self._import_closed_data = import_closed_data + self._user = user + self._client = AsanaClient.oauth(token=token) + + def list_projects(self): + projects = [] + for ws in self._client.workspaces.find_all(): + for project in self._client.projects.find_all(workspace=ws['gid']): + project = self._client.projects.find_by_id(project['gid']) + projects.append({ + "id": project['gid'], + "name": "{}/{}".format(ws['name'], project['name']), + "description": project['notes'], + "is_private": True, + }) + return projects + + def list_users(self, project_id): + users = [] + for ws in self._client.workspaces.find_all(): + for user in self._client.users.find_by_workspace(ws['gid'], fields=["gid", "name", "email", "photo"]): + users.append({ + "id": user["gid"], + "full_name": user['name'], + "detected_user": self._get_user(user), + "avatar": user.get('photo', None) and user['photo'].get('image_60x60', None) + }) + return users + + def _get_user(self, user, default=None): + if not user: + return default + + try: + return User.objects.get(email=user['email']) + except User.DoesNotExist: + pass + + return default + + def import_project(self, project_id, options): + project = self._client.projects.find_by_id(project_id) + taiga_project = self._import_project_data(project, options) + self._import_user_stories_data(taiga_project, project, options) + Timeline.objects.filter(project=taiga_project).delete() + rebuild_timeline(None, None, taiga_project.id) + return taiga_project + + def _import_project_data(self, project, options): + users_bindings = { + str(gid): user for gid, user in options.get('users_bindings', {}).items() + } + project_template = ProjectTemplate.objects.get(slug=options.get('template', 'scrum')) + + project_template.us_statuses = [] + project_template.us_statuses.append({ + "name": "Open", + "slug": "open", + "is_closed": False, + "is_archived": False, + "color": "#ff8a84", + "wip_limit": None, + "order": 1, + }) + project_template.us_statuses.append({ + "name": "Closed", + "slug": "closed", + "is_closed": True, + "is_archived": False, + "color": "#669900", + "wip_limit": None, + "order": 2, + }) + project_template.default_options["us_status"] = "Open" + + project_template.task_statuses = [] + project_template.task_statuses.append({ + "name": "Open", + "slug": "open", + "is_closed": False, + "color": "#ff8a84", + "order": 1, + }) + project_template.task_statuses.append({ + "name": "Closed", + "slug": "closed", + "is_closed": True, + "color": "#669900", + "order": 2, + }) + project_template.default_options["task_status"] = "Open" + + project_template.roles.append({ + "name": "Asana", + "slug": "asana", + "computable": False, + "permissions": project_template.roles[0]['permissions'], + "order": 70, + }) + + tags_colors = [] + for tag in self._client.tags.find_by_workspace(project['workspace']['gid'], fields=["name", "color"]): + name = tag['name'].lower() + color = tag['color'] + tags_colors.append([name, color]) + + taiga_project = Project.objects.create( + name=options.get('name', None) or project['name'], + description=options.get('description', None) or project['notes'], + owner=self._user, + tags_colors=tags_colors, + creation_template=project_template, + is_private=options.get('is_private', False) + ) + + import_service.create_memberships(users_bindings, taiga_project, self._user, "asana") + + UserStoryCustomAttribute.objects.create( + name="Due date", + description="Due date", + type="date", + order=1, + project=taiga_project + ) + + TaskCustomAttribute.objects.create( + name="Due date", + description="Due date", + type="date", + order=1, + project=taiga_project + ) + + return taiga_project + + def _import_user_stories_data(self, taiga_project, project, options): + users_bindings = { + str(gid): user for gid, user in options.get('users_bindings', {}).items() + } + tasks = self._client.tasks.find_by_project( + project['gid'], + fields=["parent", "tags", "name", "notes", "tags.name", + "completed", "followers", "modified_at", "created_at", + "project", "due_on", "assignee"] + ) + due_date_field = taiga_project.userstorycustomattributes.first() + + for task in tasks: + if task['parent']: + continue + + tags = [] + for tag in task['tags']: + tags.append(tag['name'].lower()) + + assigned_to = None + assignee = task.get('assignee', {}) + if assignee: + assigned_to = users_bindings.get(assignee.get('gid', None)) + + external_reference = None + if options.get('keep_external_reference', False): + external_url = "https://app.asana.com/0/{}/{}".format( + project['gid'], + task['gid'], + ) + external_reference = ["asana", external_url] + + us = UserStory.objects.create( + project=taiga_project, + owner=self._user, + assigned_to=assigned_to, + status=taiga_project.us_statuses.get(slug="closed" if task['completed'] else "open"), + kanban_order=task['gid'], + sprint_order=task['gid'], + backlog_order=task['gid'], + subject=task['name'], + description=task.get('notes', ""), + tags=tags, + external_reference=external_reference + ) + + if task['due_on']: + us.custom_attributes_values.attributes_values = {due_date_field.id: task['due_on']} + us.custom_attributes_values.save() + + for follower in task['followers']: + follower_user = users_bindings.get(follower['gid'], None) + if follower_user is not None: + us.add_watcher(follower_user) + + UserStory.objects.filter(id=us.id).update( + modified_date=task['modified_at'], + created_date=task['created_at'] + ) + + subtasks = self._client.tasks.subtasks( + task['gid'], + fields=["parent", "tags", "name", "notes", "tags.name", + "completed", "followers", "modified_at", "created_at", + "due_on"] + ) + for subtask in subtasks: + self._import_task_data(taiga_project, us, project, subtask, options) + + take_snapshot(us, comment="", user=None, delete=False) + self._import_history(us, task, options) + self._import_attachments(us, task, options) + + def _import_task_data(self, taiga_project, us, assana_project, task, options): + users_bindings = { + str(gid): user for gid, user in options.get('users_bindings', {}).items() + } + tags = [] + for tag in task['tags']: + tags.append(tag['name'].lower()) + due_date_field = taiga_project.taskcustomattributes.first() + + assigned_to = users_bindings.get(task.get('assignee', {}).get('gid', None)) or None + + external_reference = None + if options.get('keep_external_reference', False): + external_url = "https://app.asana.com/0/{}/{}".format( + assana_project['gid'], + task['gid'], + ) + external_reference = ["asana", external_url] + + taiga_task = Task.objects.create( + project=taiga_project, + user_story=us, + owner=self._user, + assigned_to=assigned_to, + status=taiga_project.task_statuses.get(slug="closed" if task['completed'] else "open"), + us_order=task['gid'], + taskboard_order=task['gid'], + subject=task['name'], + description=task.get('notes', ""), + tags=tags, + external_reference=external_reference + ) + + if task['due_on']: + taiga_task.custom_attributes_values.attributes_values = {due_date_field.id: task['due_on']} + taiga_task.custom_attributes_values.save() + + for follower in task['followers']: + follower_user = users_bindings.get(follower['gid'], None) + if follower_user is not None: + taiga_task.add_watcher(follower_user) + + Task.objects.filter(id=taiga_task.id).update( + modified_date=task['modified_at'], + created_date=task['created_at'] + ) + + subtasks = self._client.tasks.subtasks( + task['gid'], + fields=["parent", "tags", "name", "notes", "tags.name", + "completed", "followers", "modified_at", "created_at", + "due_on"] + ) + for subtask in subtasks: + self._import_task_data(taiga_project, us, assana_project, subtask, options) + + take_snapshot(taiga_task, comment="", user=None, delete=False) + self._import_history(taiga_task, task, options) + self._import_attachments(taiga_task, task, options) + + def _import_history(self, obj, task, options): + users_bindings = { + str(gid): user for gid, user in options.get('users_bindings', {}).items() + } + stories = self._client.stories.find_by_task(task['gid']) + for story in stories: + if story['type'] == "comment": + snapshot = take_snapshot( + obj, + comment=story['text'], + user=users_bindings.get(story['created_by']['gid'], User(full_name=story['created_by']['name'])), + delete=False + ) + HistoryEntry.objects.filter(id=snapshot.id).update(created_at=story['created_at']) + + def _import_attachments(self, obj, task, options): + attachments = self._client.attachments.find_by_task( + task['gid'], + fields=['name', 'download_url', 'created_at'] + ) + for attachment in attachments: + data = requests.get(attachment['download_url']) + att = Attachment( + owner=self._user, + project=obj.project, + content_type=ContentType.objects.get_for_model(obj), + object_id=obj.id, + name=attachment['name'], + size=len(data.content), + created_date=attachment['created_at'], + is_deprecated=False, + ) + att.attached_file.save(attachment['name'], ContentFile(data.content), save=True) + + @classmethod + def get_auth_url(cls, client_id, client_secret, callback_url=None): + client = AsanaClient.oauth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=callback_url + ) + (url, state) = client.session.authorization_url() + return url + + @classmethod + def get_access_token(cls, code, client_id, client_secret, callback_url=None): + client = AsanaClient.oauth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=callback_url + ) + return client.session.fetch_token(code=code) diff --git a/taiga/importers/asana/tasks.py b/taiga/importers/asana/tasks.py new file mode 100644 index 000000000..e993e2986 --- /dev/null +++ b/taiga/importers/asana/tasks.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import logging +import sys + +from django.utils.translation import gettext as _ + +from taiga.base.mails import mail_builder +from taiga.users.models import User +from taiga.celery import app +from .importer import AsanaImporter + +logger = logging.getLogger('taiga.importers.asana') + + +@app.task(bind=True) +def import_project(self, user_id, token, project_id, options): + user = User.objects.get(id=user_id) + importer = AsanaImporter(user, token) + try: + project = importer.import_project(project_id, options) + except Exception as e: + # Error + ctx = { + "user": user, + "error_subject": _("Error importing Asana project"), + "error_message": _("Error importing Asana project"), + "project": project_id, + "exception": e + } + email = mail_builder.importer_import_error(user, ctx) + email.send() + logger.error('Error importing Asana project %s (by %s)', project_id, user, exc_info=sys.exc_info()) + else: + ctx = { + "project": project, + "user": user, + } + email = mail_builder.asana_import_success(user, ctx) + email.send() diff --git a/taiga/importers/exceptions.py b/taiga/importers/exceptions.py new file mode 100644 index 000000000..a167d3c75 --- /dev/null +++ b/taiga/importers/exceptions.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +class InvalidRequest(Exception): + pass + +class InvalidAuthResult(Exception): + pass + +class InvalidServiceConfiguration(Exception): + pass + +class FailedRequest(Exception): + pass diff --git a/taiga/importers/github/api.py b/taiga/importers/github/api.py new file mode 100644 index 000000000..c1e853aa9 --- /dev/null +++ b/taiga/importers/github/api.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ +from django.conf import settings + +from taiga.base.api import viewsets +from taiga.base import response +from taiga.base import exceptions as exc +from taiga.base.decorators import list_route +from taiga.users.services import get_user_photo_url +from taiga.users.gravatar import get_user_gravatar_id + +from taiga.importers import permissions +from taiga.importers import exceptions +from taiga.importers.services import resolve_users_bindings +from .importer import GithubImporter +from . import tasks + + +class GithubImporterViewSet(viewsets.ViewSet): + permission_classes = (permissions.ImporterPermission,) + + @list_route(methods=["POST"]) + def list_users(self, request, *args, **kwargs): + self.check_permissions(request, "list_users", None) + + token = request.DATA.get('token', None) + project_id = request.DATA.get('project', None) + + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + importer = GithubImporter(request.user, token) + users = importer.list_users(project_id) + for user in users: + if user['detected_user']: + user['user'] = { + 'id': user['detected_user'].id, + 'full_name': user['detected_user'].get_full_name(), + 'gravatar_id': get_user_gravatar_id(user['detected_user']), + 'photo': get_user_photo_url(user['detected_user']), + } + del(user['detected_user']) + return response.Ok(users) + + @list_route(methods=["POST"]) + def list_projects(self, request, *args, **kwargs): + self.check_permissions(request, "list_projects", None) + token = request.DATA.get('token', None) + importer = GithubImporter(request.user, token) + projects = importer.list_projects() + return response.Ok(projects) + + @list_route(methods=["POST"]) + def import_project(self, request, *args, **kwargs): + self.check_permissions(request, "import_project", None) + + token = request.DATA.get('token', None) + project_id = request.DATA.get('project', None) + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + template = request.DATA.get('template', "scrum") + items_type = "user_stories" + if template == "issues": + items_type = "issues" + template = "scrum" + + options = { + "name": request.DATA.get('name', None), + "description": request.DATA.get('description', None), + "template": template, + "type": items_type, + "users_bindings": resolve_users_bindings(request.DATA.get("users_bindings", {})), + "keep_external_reference": request.DATA.get("keep_external_reference", False), + "is_private": request.DATA.get("is_private", False), + } + + if settings.CELERY_ENABLED: + task = tasks.import_project.delay(request.user.id, token, project_id, options) + return response.Accepted({"task_id": task.id}) + + importer = GithubImporter(request.user, token) + project = importer.import_project(project_id, options) + project_data = { + "slug": project.slug, + "my_permissions": ["view_us"], + "is_backlog_activated": project.is_backlog_activated, + "is_kanban_activated": project.is_kanban_activated, + } + + return response.Ok(project_data) + + @list_route(methods=["GET"]) + def auth_url(self, request, *args, **kwargs): + self.check_permissions(request, "auth_url", None) + callback_uri = request.QUERY_PARAMS.get('uri') + url = GithubImporter.get_auth_url( + settings.IMPORTERS.get('github', {}).get('client_id', None), + callback_uri + ) + return response.Ok({"url": url}) + + @list_route(methods=["POST"]) + def authorize(self, request, *args, **kwargs): + self.check_permissions(request, "authorize", None) + + code = request.DATA.get('code', None) + if code is None: + raise exc.BadRequest(_("Code param needed")) + + try: + token = GithubImporter.get_access_token( + settings.IMPORTERS.get('github', {}).get('client_id', None), + settings.IMPORTERS.get('github', {}).get('client_secret', None), + code + ) + return response.Ok({ + "token": token + }) + except exceptions.InvalidAuthResult: + raise exc.BadRequest(_("Invalid auth data")) + except exceptions.FailedRequest: + raise exc.BadRequest(_("Third party service failing")) diff --git a/taiga/importers/github/importer.py b/taiga/importers/github/importer.py new file mode 100644 index 000000000..b6206d9c1 --- /dev/null +++ b/taiga/importers/github/importer.py @@ -0,0 +1,615 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime +import requests +from urllib.parse import parse_qsl +from django.core.files.base import ContentFile + +from taiga.projects.models import Project, ProjectTemplate +from taiga.projects.references.models import recalc_reference_counter +from taiga.projects.userstories.models import UserStory +from taiga.projects.issues.models import Issue +from taiga.projects.milestones.models import Milestone +from taiga.projects.history.services import take_snapshot +from taiga.projects.history.services import (make_diff_from_dicts, + make_diff_values, + make_key_from_model_object, + get_typename_for_model_class, + FrozenDiff) +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.choices import HistoryType +from taiga.timeline.rebuilder import rebuild_timeline +from taiga.timeline.models import Timeline +from taiga.users.models import User, AuthData + +from taiga.importers.exceptions import InvalidAuthResult, FailedRequest +from taiga.importers import services as import_service + + +class GithubClient: + def __init__(self, token): + self.api_url = "https://api.github.com/{}" + self.token = token + + def get(self, uri_path, query_params=None): + headers = { + "Content-Type": "application/json", + "X-GitHub-Media-Type": "github.v3" + } + if self.token: + headers['Authorization'] = 'token {}'.format(self.token) + + if query_params is None: + query_params = {} + + if uri_path[0] == '/': + uri_path = uri_path[1:] + url = self.api_url.format(uri_path) + + response = requests.get(url, params=query_params, headers=headers) + + if response.status_code == 401: + raise Exception("Unauthorized: %s at %s" % (response.text, url), response) + if response.status_code != 200: + raise Exception("Resource Unavailable: %s at %s" % (response.text, url), response) + + return response.json() + + +class GithubImporter: + def __init__(self, user, token, import_closed_data=False): + self._import_closed_data = import_closed_data + self._user = user + self._client = GithubClient(token) + self._me = self._client.get("/user") + + def list_projects(self): + projects = [] + page = 1 + while True: + repos = self._client.get("/user/repos", { + "sort": "full_name", + "page": page, + "per_page": 100 + }) + page += 1 + + for repo in repos: + projects.append({ + "id": repo['full_name'], + "name": repo['full_name'], + "description": repo['description'], + "is_private": repo['private'], + }) + + if len(repos) < 100: + break + return projects + + def list_users(self, project_full_name): + collaborators = self._client.get("/repos/{}/collaborators".format(project_full_name)) + collaborators = [self._client.get("/users/{}".format(u['login'])) for u in collaborators] + return [{"id": u['id'], + "username": u['login'], + "full_name": u.get('name', u['login']), + "avatar": u.get('avatar_url', None), + "detected_user": self._get_user(u) } for u in collaborators] + + def _get_user(self, user, default=None): + if not user: + return default + + try: + return AuthData.objects.get(key="github", value=user['id']).user + except AuthData.DoesNotExist: + pass + + try: + return User.objects.get(email=user.get('email', "not-valid")) + except User.DoesNotExist: + pass + + return default + + def import_project(self, project_full_name, options={"keep_external_reference": False, "template": "kanban", "type": "user_stories"}): + repo = self._client.get('/repos/{}'.format(project_full_name)) + project = self._import_project_data(repo, options) + if options.get('type', None) == "user_stories": + self._import_user_stories_data(project, repo, options) + elif options.get('type', None) == "issues": + self._import_issues_data(project, repo, options) + self._import_comments(project, repo, options) + self._import_history(project, repo, options) + Timeline.objects.filter(project=project).delete() + rebuild_timeline(None, None, project.id) + recalc_reference_counter(project) + return project + + def _import_project_data(self, repo, options): + users_bindings = options.get('users_bindings', {}) + project_template = ProjectTemplate.objects.get(slug=options['template']) + + if options['type'] == "user_stories": + project_template.us_statuses = [] + project_template.us_statuses.append({ + "name": "Open", + "slug": "open", + "is_closed": False, + "is_archived": False, + "color": "#ff8a84", + "wip_limit": None, + "order": 1, + }) + project_template.us_statuses.append({ + "name": "Closed", + "slug": "closed", + "is_closed": True, + "is_archived": False, + "color": "#669900", + "wip_limit": None, + "order": 2, + }) + project_template.default_options["us_status"] = "Open" + elif options['type'] == "issues": + project_template.issue_statuses = [] + project_template.issue_statuses.append({ + "name": "Open", + "slug": "open", + "is_closed": False, + "color": "#ff8a84", + "order": 1, + }) + project_template.issue_statuses.append({ + "name": "Closed", + "slug": "closed", + "is_closed": True, + "color": "#669900", + "order": 2, + }) + project_template.default_options["issue_status"] = "Open" + + project_template.roles.append({ + "name": "Github", + "slug": "github", + "computable": False, + "permissions": project_template.roles[0]['permissions'], + "order": 70, + }) + + tags_colors = [] + for label in self._client.get("/repos/{}/labels".format(repo['full_name'])): + name = label['name'].lower() + color = "#{}".format(label['color']) + tags_colors.append([name, color]) + + project = Project.objects.create( + name=options.get('name', None) or repo['full_name'], + description=options.get('description', None) or repo['description'], + owner=self._user, + tags_colors=tags_colors, + creation_template=project_template, + is_private=options.get('is_private', False), + ) + + if 'organization' in repo and repo['organization'].get('avatar_url', None): + data = requests.get(repo['organization']['avatar_url']) + project.logo.save("logo.png", ContentFile(data.content), save=True) + + import_service.create_memberships(options.get('users_bindings', {}), project, self._user, "github") + + for milestone in self._client.get("/repos/{}/milestones".format(repo['full_name']), {"state": "all"}): + taiga_milestone = Milestone.objects.create( + name=milestone['title'], + owner=users_bindings.get(milestone.get('creator', {}).get('id', None), self._user), + project=project, + estimated_start=milestone['created_at'][:10], + estimated_finish=milestone['due_on'][:10] if milestone['due_on'] else datetime.date(datetime.MAXYEAR, 12, 31), + ) + Milestone.objects.filter(id=taiga_milestone.id).update( + created_date=milestone['created_at'], + modified_date=milestone['updated_at'], + ) + return project + + def _import_user_stories_data(self, project, repo, options): + users_bindings = options.get('users_bindings', {}) + + page = 1 + while True: + issues = self._client.get("/repos/{}/issues".format(repo['full_name']), { + "state": "all", + "sort": "created", + "direction": "asc", + "page": page, + "per_page": 100 + }) + page += 1 + for issue in issues: + tags = [] + for label in issue['labels']: + tags.append(label['name'].lower()) + + assigned_to = users_bindings.get(issue['assignee']['id'] if issue['assignee'] else None, None) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["github", issue['html_url']] + + milestone = None + if issue['milestone']: + try: + milestone = project.milestones.get(name=issue['milestone']['title']) + except Milestone.DoesNotExist: + milestone = None + + us = UserStory.objects.create( + ref=issue['number'], + project=project, + owner=users_bindings.get(issue['user']['id'], self._user), + milestone=milestone, + assigned_to=assigned_to, + status=project.us_statuses.get(slug=issue['state']), + kanban_order=issue['number'], + sprint_order=issue['number'], + backlog_order=issue['number'], + subject=issue['title'], + description=issue.get("body", "") or "", + tags=tags, + external_reference=external_reference, + modified_date=issue['updated_at'], + created_date=issue['created_at'], + ) + + assignees = issue.get('assignees', []) + if len(assignees) > 1: + for assignee in assignees: + if assignee['id'] != issue.get('assignee', {}).get('id', None): + assignee_user = users_bindings.get(assignee['id'], None) + if assignee_user is not None: + us.add_watcher(assignee_user) + + UserStory.objects.filter(id=us.id).update( + ref=issue['number'], + modified_date=issue['updated_at'], + created_date=issue['created_at'] + ) + + take_snapshot(us, comment="", user=None, delete=False) + + if len(issues) < 100: + break + + def _import_issues_data(self, project, repo, options): + users_bindings = options.get('users_bindings', {}) + + page = 1 + while True: + issues = self._client.get("/repos/{}/issues".format(repo['full_name']), { + "state": "all", + "sort": "created", + "direction": "asc", + "page": page, + "per_page": 100 + }) + page += 1 + for issue in issues: + tags = [] + for label in issue['labels']: + tags.append(label['name'].lower()) + + assigned_to = users_bindings.get(issue['assignee']['id'] if issue['assignee'] else None, None) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["github", issue['html_url']] + + taiga_issue = Issue.objects.create( + ref=issue['number'], + project=project, + owner=users_bindings.get(issue['user']['id'], self._user), + assigned_to=assigned_to, + status=project.issue_statuses.get(slug=issue['state']), + subject=issue['title'], + description=issue.get('body', "") or "", + tags=tags, + external_reference=external_reference, + modified_date=issue['updated_at'], + created_date=issue['created_at'], + ) + + assignees = issue.get('assignees', []) + if len(assignees) > 1: + for assignee in assignees: + if assignee['id'] != issue.get('assignee', {}).get('id', None): + assignee_user = users_bindings.get(assignee['id'], None) + if assignee_user is not None: + taiga_issue.add_watcher(assignee_user) + + Issue.objects.filter(id=taiga_issue.id).update( + ref=issue['number'], + modified_date=issue['updated_at'], + created_date=issue['created_at'] + ) + + take_snapshot(taiga_issue, comment="", user=None, delete=False) + + if len(issues) < 100: + break + + def _import_comments(self, project, repo, options): + users_bindings = options.get('users_bindings', {}) + + page = 1 + while True: + comments = self._client.get("/repos/{}/issues/comments".format(repo['full_name']), { + "page": page, + "per_page": 100 + }) + page += 1 + + for comment in comments: + issue_id = comment['issue_url'].split("/")[-1] + if options.get('type', None) == "user_stories": + obj = UserStory.objects.get(project=project, ref=issue_id) + elif options.get('type', None) == "issues": + obj = Issue.objects.get(project=project, ref=issue_id) + + snapshot = take_snapshot( + obj, + comment=comment['body'], + user=users_bindings.get(comment['user']['id'], User(full_name=comment['user'].get('name', None) or comment['user']['login'])), + delete=False + ) + HistoryEntry.objects.filter(id=snapshot.id).update(created_at=comment['created_at']) + + if len(comments) < 100: + break + + def _import_history(self, project, repo, options): + cumulative_data = {} + page = 1 + all_events = [] + while True: + events = self._client.get("/repos/{}/issues/events".format(repo['full_name']), { + "page": page, + "per_page": 100 + }) + page += 1 + all_events = all_events + events + + if len(events) < 100: + break + + for event in sorted(all_events, key=lambda x: x['id']): + if options.get('type', None) == "user_stories": + obj = UserStory.objects.get(project=project, ref=event['issue']['number']) + elif options.get('type', None) == "issues": + obj = Issue.objects.get(project=project, ref=event['issue']['number']) + + if event['issue']['number'] in cumulative_data: + obj_cumulative_data = cumulative_data[event['issue']['number']] + else: + obj_cumulative_data = { + "tags": set(), + "assigned_to": None, + "assigned_to_github_id": None, + "assigned_to_name": None, + "milestone": None, + } + cumulative_data[event['issue']['number']] = obj_cumulative_data + self._import_event(obj, event, options, obj_cumulative_data) + + def _import_event(self, obj, event, options, cumulative_data): + typename = get_typename_for_model_class(type(obj)) + key = make_key_from_model_object(obj) + event_data = self._transform_event_data(obj, event, options, cumulative_data) + if event_data is None: + return + + change_old = event_data['change_old'] + change_new = event_data['change_new'] + user = event_data['user'] + + diff = make_diff_from_dicts(change_old, change_new) + fdiff = FrozenDiff(key, diff, {}) + values = make_diff_values(typename, fdiff) + values.update(event_data['update_values']) + entry = HistoryEntry.objects.create( + user=user, + project_id=obj.project.id, + key=key, + type=HistoryType.change, + snapshot=None, + diff=fdiff.diff, + values=values, + comment="", + comment_html="", + is_hidden=False, + is_snapshot=False, + ) + HistoryEntry.objects.filter(id=entry.id).update(created_at=event['created_at']) + return HistoryEntry.objects.get(id=entry.id) + + def _transform_event_data(self, obj, event, options, cumulative_data): + users_bindings = options.get('users_bindings', {}) + + ignored_events = ["committed", "cross-referenced", "head_ref_deleted", + "head_ref_restored", "locked", "unlocked", "merged", + "referenced", "mentioned", "subscribed", + "unsubscribed"] + + if event['event'] in ignored_events: + return None + + user = {"pk": None, "name": event['actor'].get('name', event['actor']['login'])} + taiga_user = users_bindings.get(event['actor']['id'], None) if event['actor'] else None + if taiga_user: + user = {"pk": taiga_user.id, "name": taiga_user.get_full_name()} + + result = { + "change_old": {}, + "change_new": {}, + "user": user, + "update_values": {}, + } + + if event['event'] == "renamed": + result['change_old']["subject"] = event['rename']['from'] + result['change_new']["subject"] = event['rename']['to'] + elif event['event'] == "reopened": + if isinstance(obj, Issue): + result['change_old']["status"] = obj.project.issue_statuses.get(name='Closed').id + result['change_new']["status"] = obj.project.issue_statuses.get(name='Open').id + elif isinstance(obj, UserStory): + result['change_old']["status"] = obj.project.us_statuses.get(name='Closed').id + result['change_new']["status"] = obj.project.us_statuses.get(name='Open').id + elif event['event'] == "closed": + if isinstance(obj, Issue): + result['change_old']["status"] = obj.project.issue_statuses.get(name='Open').id + result['change_new']["status"] = obj.project.issue_statuses.get(name='Closed').id + elif isinstance(obj, UserStory): + result['change_old']["status"] = obj.project.us_statuses.get(name='Open').id + result['change_new']["status"] = obj.project.us_statuses.get(name='Closed').id + elif event['event'] == "assigned": + AssignedEventHandler(result, cumulative_data, users_bindings).handle(event) + elif event['event'] == "unassigned": + UnassignedEventHandler(result, cumulative_data, users_bindings).handle(event) + elif event['event'] == "demilestoned": + if isinstance(obj, UserStory): + try: + result['change_old']["milestone"] = obj.project.milestones.get(name=event['milestone']['title']).id + except Milestone.DoesNotExist: + result['change_old']["milestone"] = 0 + result['update_values'] = {"milestone": {"0": event['milestone']['title']}} + result['change_new']["milestone"] = None + cumulative_data['milestone'] = None + elif event['event'] == "milestoned": + if isinstance(obj, UserStory): + result['update_values']["milestone"] = {} + if cumulative_data['milestone'] is not None: + result['update_values']['milestone'][str(cumulative_data['milestone'])] = cumulative_data['milestone_name'] + result['change_old']["milestone"] = cumulative_data['milestone'] + try: + taiga_milestone = obj.project.milestones.get(name=event['milestone']['title']) + cumulative_data["milestone"] = taiga_milestone.id + cumulative_data['milestone_name'] = taiga_milestone.name + except Milestone.DoesNotExist: + if cumulative_data['milestone'] == 0: + cumulative_data['milestone'] = -1 + else: + cumulative_data['milestone'] = 0 + cumulative_data['milestone_name'] = event['milestone']['title'] + result['change_new']["milestone"] = cumulative_data['milestone'] + result['update_values']['milestone'][str(cumulative_data['milestone'])] = cumulative_data['milestone_name'] + elif event['event'] == "labeled": + result['change_old']["tags"] = list(cumulative_data['tags']) + cumulative_data['tags'].add(event['label']['name'].lower()) + result['change_new']["tags"] = list(cumulative_data['tags']) + elif event['event'] == "unlabeled": + result['change_old']["tags"] = list(cumulative_data['tags']) + if event['label']['name'].lower() in cumulative_data['tags']: + cumulative_data['tags'].remove(event['label']['name'].lower()) + result['change_new']["tags"] = list(cumulative_data['tags']) + + return result + + @classmethod + def get_auth_url(cls, client_id, callback_uri=None): + if callback_uri is None: + return "https://github.com/login/oauth/authorize?client_id={}&scope=user,repo".format(client_id) + return "https://github.com/login/oauth/authorize?client_id={}&scope=user,repo&redirect_uri={}".format(client_id, callback_uri) + + @classmethod + def get_access_token(cls, client_id, client_secret, code): + try: + result = requests.post("https://github.com/login/oauth/access_token", { + "client_id": client_id, + "client_secret": client_secret, + "code": code, + }) + except Exception: + raise FailedRequest() + + if result.status_code > 299: + raise InvalidAuthResult() + else: + try: + return dict(parse_qsl(result.content))[b'access_token'].decode('utf-8') + except: + raise InvalidAuthResult() + + +class AssignedEventHandler: + def __init__(self, result, cumulative_data, users_bindings): + self.result = result + self.cumulative_data = cumulative_data + self.users_bindings = users_bindings + + def handle(self, event): + if self.cumulative_data['assigned_to_github_id'] is None: + self.result['update_values']["users"] = {} + self.generate_change_old(event) + self.generate_update_values_from_cumulative_data(event) + user = self.users_bindings.get(event['assignee']['id'], None) + self.generate_change_new(event, user) + self.update_cumulative_data(event, user) + self.generate_update_values_from_cumulative_data(event) + + def generate_change_old(self, event): + self.result['change_old']["assigned_to"] = self.cumulative_data['assigned_to'] + + def generate_update_values_from_cumulative_data(self, event): + if self.cumulative_data['assigned_to_name'] is not None: + self.result['update_values']["users"][str(self.cumulative_data['assigned_to'])] = self.cumulative_data['assigned_to_name'] + + def generate_change_new(self, event, user): + if user is None: + self.result['change_new']["assigned_to"] = 0 + else: + self.result['change_new']["assigned_to"] = user.id + + def update_cumulative_data(self, event, user): + self.cumulative_data['assigned_to_github_id'] = event['assignee']['id'] + if user is None: + self.cumulative_data['assigned_to'] = 0 + self.cumulative_data['assigned_to_name'] = event['assignee']['login'] + else: + self.cumulative_data['assigned_to'] = user.id + self.cumulative_data['assigned_to_name'] = user.get_full_name() + + +class UnassignedEventHandler: + def __init__(self, result, cumulative_data, users_bindings): + self.result = result + self.cumulative_data = cumulative_data + self.users_bindings = users_bindings + + def handle(self, event): + if self.cumulative_data['assigned_to_github_id'] == event['assignee']['id']: + self.result['update_values']["users"] = {} + + self.generate_change_old(event) + self.generate_update_values_from_cumulative_data(event) + self.generate_change_new(event) + self.update_cumulative_data(event) + self.generate_update_values_from_cumulative_data(event) + + def generate_change_old(self, event): + self.result['change_old']["assigned_to"] = self.cumulative_data['assigned_to'] + + def generate_update_values_from_cumulative_data(self, event): + if self.cumulative_data['assigned_to_name'] is not None: + self.result['update_values']["users"][str(self.cumulative_data['assigned_to'])] = self.cumulative_data['assigned_to_name'] + + def generate_change_new(self, event): + self.result['change_new']["assigned_to"] = None + + def update_cumulative_data(self, event): + self.cumulative_data['assigned_to_github_id'] = None + self.cumulative_data['assigned_to'] = None + self.cumulative_data['assigned_to_name'] = None diff --git a/taiga/importers/github/tasks.py b/taiga/importers/github/tasks.py new file mode 100644 index 000000000..ee7ae014d --- /dev/null +++ b/taiga/importers/github/tasks.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import logging +import sys + +from django.utils.translation import gettext as _ + +from taiga.base.mails import mail_builder +from taiga.celery import app +from taiga.users.models import User +from .importer import GithubImporter + +logger = logging.getLogger('taiga.importers.github') + + +@app.task(bind=True) +def import_project(self, user_id, token, project_id, options): + user = User.objects.get(id=user_id) + importer = GithubImporter(user, token) + try: + project = importer.import_project(project_id, options) + except Exception as e: + # Error + ctx = { + "user": user, + "error_subject": _("Error importing GitHub project"), + "error_message": _("Error importing GitHub project"), + "project": project_id, + "exception": e + } + email = mail_builder.importer_import_error(user, ctx) + email.send() + logger.error('Error importing GitHub project %s (by %s)', project_id, user, exc_info=sys.exc_info()) + else: + ctx = { + "project": project, + "user": user, + } + email = mail_builder.github_import_success(user, ctx) + email.send() diff --git a/taiga/importers/jira/agile.py b/taiga/importers/jira/agile.py new file mode 100644 index 000000000..90b730aee --- /dev/null +++ b/taiga/importers/jira/agile.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime +from collections import OrderedDict + +from django.template.defaultfilters import slugify +from django.utils import timezone + +from taiga.importers import services as import_service +from taiga.projects.references.models import recalc_reference_counter +from taiga.projects.models import Project, ProjectTemplate, Points +from taiga.projects.userstories.models import UserStory, RolePoints +from taiga.projects.tasks.models import Task +from taiga.projects.milestones.models import Milestone +from taiga.projects.epics.models import Epic, RelatedUserStory +from taiga.projects.history.services import take_snapshot +from taiga.timeline.rebuilder import rebuild_timeline +from taiga.timeline.models import Timeline + +from .common import JiraImporterCommon + + +class JiraAgileImporter(JiraImporterCommon): + def list_projects(self): + return [{"id": board['id'], + "name": board['name'], + "description": "", + "is_private": True, + "importer_type": "agile"} for board in self._client.get_agile('/board')['values']] + + def list_issue_types(self, project_id): + board_project = self._client.get_agile("/board/{}/project".format(project_id))['values'][0] + statuses = self._client.get("/project/{}/statuses".format(board_project['id'])) + return statuses + + def import_project(self, project_id, options=None): + self.resolve_user_bindings(options) + project = self._import_project_data(project_id, options) + self._import_epics_data(project_id, project, options) + self._import_user_stories_data(project_id, project, options) + self._cleanup(project, options) + Timeline.objects.filter(project=project).delete() + rebuild_timeline(None, None, project.id) + recalc_reference_counter(project) + return project + + def _import_project_data(self, project_id, options): + project = self._client.get_agile("/board/{}".format(project_id)) + project_config = self._client.get_agile("/board/{}/configuration".format(project_id)) + if project['type'] == "scrum": + project_template = ProjectTemplate.objects.get(slug="scrum") + options['type'] = "scrum" + elif project['type'] == "kanban": + project_template = ProjectTemplate.objects.get(slug="kanban") + options['type'] = "kanban" + + project_template.is_epics_activated = True + project_template.epic_statuses = OrderedDict() + project_template.us_statuses = OrderedDict() + project_template.task_statuses = OrderedDict() + project_template.issue_statuses = OrderedDict() + + counter = 0 + for column in project_config['columnConfig']['columns']: + column_slug = slugify(column['name']) + project_template.epic_statuses[column_slug] = { + "name": column['name'], + "slug": column_slug, + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + } + project_template.us_statuses[column_slug] = { + "name": column['name'], + "slug": column_slug, + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + } + project_template.task_statuses[column_slug] = { + "name": column['name'], + "slug": column_slug, + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + } + project_template.issue_statuses[column_slug] = { + "name": column['name'], + "slug": column_slug, + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + } + counter += 1 + + project_template.epic_statuses = list(project_template.epic_statuses.values()) + project_template.us_statuses = list(project_template.us_statuses.values()) + project_template.task_statuses = list(project_template.task_statuses.values()) + project_template.issue_statuses = list(project_template.issue_statuses.values()) + project_template.default_options["epic_status"] = project_template.epic_statuses[0]['name'] + project_template.default_options["us_status"] = project_template.us_statuses[0]['name'] + project_template.default_options["task_status"] = project_template.task_statuses[0]['name'] + project_template.default_options["issue_status"] = project_template.issue_statuses[0]['name'] + + project_template.points = [{ + "value": None, + "name": "?", + "order": 0, + }] + + main_permissions = project_template.roles[0]['permissions'] + project_template.roles = [{ + "name": "Main", + "slug": "main", + "computable": True, + "permissions": main_permissions, + "order": 70, + }] + + project = Project.objects.create( + name=options.get('name', None) or project['name'], + description=options.get('description', None) or project.get('description', ''), + owner=self._user, + creation_template=project_template, + is_private=options.get('is_private', False), + ) + + self._create_custom_fields(project) + import_service.create_memberships(options.get('users_bindings', {}), project, self._user, "main") + + if project_template.slug == "scrum": + for sprint in self._client.get_agile("/board/{}/sprint".format(project_id))['values']: + start_datetime = sprint.get('startDate', None) + end_datetime = sprint.get('startDate', None) + start_date = datetime.date.today() + if start_datetime: + start_date = start_datetime[:10] + end_date = datetime.date.today() + if end_datetime: + end_date = end_datetime[:10] + + milestone = Milestone.objects.create( + name=sprint['name'], + slug=slugify(sprint['name']), + owner=self._user, + project=project, + estimated_start=start_date, + estimated_finish=end_date, + ) + Milestone.objects.filter(id=milestone.id).update( + created_date=start_datetime or timezone.now(), + modified_date=start_datetime or timezone.now(), + ) + return project + + def _import_user_stories_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + project_conf = self._client.get_agile("/board/{}/configuration".format(project_id)) + if options['type'] == "scrum": + estimation_field = project_conf['estimation']['field']['fieldId'] + + counter = 0 + offset = 0 + while True: + issues = self._client.get_agile("/board/{}/issue".format(project_id), { + "startAt": offset, + "expand": "changelog", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", self._client.get_issue_url(issue['key'])] + + try: + milestone = project.milestones.get(name=(issue['fields'].get('sprint', {}) or {}).get('name', '')) + except Milestone.DoesNotExist: + milestone = None + + us = UserStory.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.us_statuses.get(slug=slugify(issue['fields']['status']['name'])), + kanban_order=counter, + sprint_order=counter, + backlog_order=counter, + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + milestone=milestone, + ) + + try: + epic = project.epics.get(ref=int(issue['fields'].get("epic", {}).get("key", "FAKE-0").split("-")[1])) + RelatedUserStory.objects.create( + user_story=us, + epic=epic, + order=1 + ) + except Epic.DoesNotExist: + pass + + if options['type'] == "scrum": + estimation = None + if issue['fields'].get(estimation_field, None): + estimation = float(issue['fields'].get(estimation_field)) + + (points, _) = Points.objects.get_or_create( + project=project, + value=estimation, + defaults={ + "name": str(estimation), + "order": estimation, + } + ) + RolePoints.objects.filter(user_story=us, role__slug="main").update(points_id=points.id) + + self._import_to_custom_fields(us, issue, options) + + us.ref = issue['key'].split("-")[1] + UserStory.objects.filter(id=us.id).update( + ref=us.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(us, comment="", user=None, delete=False) + self._import_subtasks(project_id, project, us, issue, options) + self._import_comments(us, issue, options) + self._import_attachments(us, issue, options) + self._import_changelog(project, us, issue, options) + counter += 1 + + if len(issues['issues']) < issues['maxResults']: + break + + def _import_subtasks(self, project_id, project, us, issue, options): + users_bindings = options.get('users_bindings', {}) + + if len(issue['fields']['subtasks']) == 0: + return + + counter = 0 + offset = 0 + while True: + issues = self._client.get_agile("/board/{}/issue".format(project_id), { + "jql": "parent={}".format(issue['key']), + "startAt": offset, + "expand": "changelog", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", self._client.get_issue_url(issue['key'])] + + task = Task.objects.create( + user_story=us, + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.task_statuses.get(slug=slugify(issue['fields']['status']['name'])), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + milestone=us.milestone, + ) + + self._import_to_custom_fields(task, issue, options) + + task.ref = issue['key'].split("-")[1] + Task.objects.filter(id=task.id).update( + ref=task.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(task, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(task, issue, options) + self._import_attachments(task, issue, options) + self._import_changelog(project, task, issue, options) + counter += 1 + if len(issues['issues']) < issues['maxResults']: + break + + def _import_epics_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + + counter = 0 + offset = 0 + while True: + issues = self._client.get_agile("/board/{}/epic".format(project_id), { + "startAt": offset, + }) + offset += issues['maxResults'] + + for epic in issues['values']: + issue = self._client.get_agile("/issue/{}".format(epic['key'])) + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["jira", self._client.get_issue_url(issue['key'])] + + epic = Epic.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.epic_statuses.get(slug=slugify(issue['fields']['status']['name'])), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + epics_order=counter, + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + self._import_to_custom_fields(epic, issue, options) + + epic.ref = issue['key'].split("-")[1] + Epic.objects.filter(id=epic.id).update( + ref=epic.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + + take_snapshot(epic, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(epic, issue, options) + self._import_attachments(epic, issue, options) + issue_with_changelog = self._client.get("/issue/{}".format(issue['key']), { + "expand": "changelog" + }) + self._import_changelog(project, epic, issue_with_changelog, options) + counter += 1 + + if len(issues['values']) < issues['maxResults']: + break diff --git a/taiga/importers/jira/api.py b/taiga/importers/jira/api.py new file mode 100644 index 000000000..deba22d9d --- /dev/null +++ b/taiga/importers/jira/api.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.utils.translation import gettext as _ +from django.conf import settings + +from taiga.base.api import viewsets +from taiga.base import response +from taiga.base import exceptions as exc +from taiga.base.decorators import list_route +from taiga.users.models import AuthData, User +from taiga.users.services import get_user_photo_url +from taiga.users.gravatar import get_user_gravatar_id + +from taiga.importers import permissions +from taiga.importers import exceptions +from taiga.importers.services import resolve_users_bindings +from .normal import JiraNormalImporter +from .agile import JiraAgileImporter +from . import tasks + + +class JiraImporterViewSet(viewsets.ViewSet): + permission_classes = (permissions.ImporterPermission,) + + def _get_token(self, request): + token_data = request.DATA.get('token', "").split(".") + + token = { + "access_token": token_data[0], + "access_token_secret": token_data[1], + "key_cert": settings.IMPORTERS.get('jira', {}).get('cert', None), + "consumer_key": settings.IMPORTERS.get('jira', {}).get('consumer_key', None) + } + return token + + @list_route(methods=["POST"]) + def list_users(self, request, *args, **kwargs): + self.check_permissions(request, "list_users", None) + + url = request.DATA.get('url', None) + token = self._get_token(request) + project_id = request.DATA.get('project', None) + + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + if not url: + raise exc.WrongArguments(_("The url param is needed")) + + importer = JiraNormalImporter(request.user, url, token) + try: + users = importer.list_users() + except Exception as e: + # common error due to modern Jira versions which are unsupported by Taiga + raise exc.BadRequest(_(""" + There was an error; probably due to an unsupported Jira version. + Taiga does not support Jira releases from 8.6.""" + )) + + for user in users: + user['user'] = None + if not user['email']: + continue + + try: + taiga_user = User.objects.get(email=user['email']) + except User.DoesNotExist: + continue + + user['user'] = { + 'id': taiga_user.id, + 'full_name': taiga_user.get_full_name(), + 'gravatar_id': get_user_gravatar_id(taiga_user), + 'photo': get_user_photo_url(taiga_user), + } + return response.Ok(users) + + @list_route(methods=["POST"]) + def list_projects(self, request, *args, **kwargs): + self.check_permissions(request, "list_projects", None) + url = request.DATA.get('url', None) + if not url: + raise exc.WrongArguments(_("The url param is needed")) + + token = self._get_token(request) + importer = JiraNormalImporter(request.user, url, token) + agile_importer = JiraAgileImporter(request.user, url, token) + projects = importer.list_projects() + boards = agile_importer.list_projects() + return response.Ok(sorted(projects + boards, key=lambda x: x['name'])) + + @list_route(methods=["POST"]) + def import_project(self, request, *args, **kwargs): + self.check_permissions(request, "import_project", None) + + url = request.DATA.get('url', None) + token = self._get_token(request) + project_id = request.DATA.get('project', None) + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + if not url: + raise exc.WrongArguments(_("The url param is needed")) + + options = { + "name": request.DATA.get('name', None), + "description": request.DATA.get('description', None), + "users_bindings": resolve_users_bindings(request.DATA.get("users_bindings", {})), + "keep_external_reference": request.DATA.get("keep_external_reference", False), + "is_private": request.DATA.get("is_private", False), + } + + importer_type = request.DATA.get('importer_type', "normal") + if importer_type == "agile": + importer = JiraAgileImporter(request.user, url, token) + else: + project_type = request.DATA.get("project_type", "scrum") + if project_type == "kanban": + options['template'] = "kanban" + else: + options['template'] = "scrum" + + importer = JiraNormalImporter(request.user, url, token) + + types_bindings = { + "epic": [], + "us": [], + "task": [], + "issue": [], + } + for issue_type in importer.list_issue_types(project_id): + if project_type in ['scrum', 'kanban']: + # Set the type bindings + if issue_type['subtask']: + types_bindings['task'].append(issue_type) + elif issue_type['name'].upper() == "EPIC": + types_bindings["epic"].append(issue_type) + elif issue_type['name'].upper() in ["US", "USERSTORY", "USER STORY"]: + types_bindings["us"].append(issue_type) + elif issue_type['name'].upper() in ["ISSUE", "BUG", "ENHANCEMENT"]: + types_bindings["issue"].append(issue_type) + else: + types_bindings["us"].append(issue_type) + elif project_type == "issues": + # Set the type bindings + if issue_type['subtask']: + continue + types_bindings["issue"].append(issue_type) + elif project_type == "issues-with-subissues": + types_bindings["issue"].append(issue_type) + else: + raise exc.WrongArguments(_("Invalid project_type {}").format(project_type)) + + options["types_bindings"] = types_bindings + + if settings.CELERY_ENABLED: + task = tasks.import_project.delay(request.user.id, url, token, project_id, options, importer_type) + return response.Accepted({"task_id": task.id}) + + project = importer.import_project(project_id, options) + project_data = { + "slug": project.slug, + "my_permissions": ["view_us"], + "is_backlog_activated": project.is_backlog_activated, + "is_kanban_activated": project.is_kanban_activated, + } + + return response.Ok(project_data) + + @list_route(methods=["GET"]) + def auth_url(self, request, *args, **kwargs): + self.check_permissions(request, "auth_url", None) + jira_url = request.QUERY_PARAMS.get('url', None) + + if not jira_url: + raise exc.WrongArguments(_("The url param is needed")) + + try: + (oauth_token, oauth_secret, url) = JiraNormalImporter.get_auth_url( + jira_url, + settings.IMPORTERS.get('jira', {}).get('consumer_key', None), + settings.IMPORTERS.get('jira', {}).get('cert', None), + True + ) + except exceptions.InvalidServiceConfiguration: + raise exc.BadRequest(_("Invalid Jira server configuration.")) + + (auth_data, created) = AuthData.objects.get_or_create( + user=request.user, + key="jira-oauth", + defaults={ + "value": uuid.uuid4().hex, + "extra": {}, + } + ) + auth_data.extra = { + "oauth_token": oauth_token, + "oauth_secret": oauth_secret, + "url": jira_url, + } + auth_data.save() + + return response.Ok({"url": url}) + + @list_route(methods=["POST"]) + def authorize(self, request, *args, **kwargs): + self.check_permissions(request, "authorize", None) + + try: + oauth_data = request.user.auth_data.get(key="jira-oauth") + oauth_verifier = request.DATA.get("oauth_verifier", None) + oauth_token = oauth_data.extra['oauth_token'] + oauth_secret = oauth_data.extra['oauth_secret'] + server_url = oauth_data.extra['url'] + oauth_data.delete() + + jira_token = JiraNormalImporter.get_access_token( + server_url, + settings.IMPORTERS.get('jira', {}).get('consumer_key', None), + settings.IMPORTERS.get('jira', {}).get('cert', None), + oauth_token, + oauth_secret, + oauth_verifier, + False + ) + except Exception as e: + raise exc.WrongArguments(_("Invalid or expired auth token")) + + return response.Ok({ + "token": jira_token['access_token'] + "." + jira_token['access_token_secret'], + "url": server_url + }) diff --git a/taiga/importers/jira/common.py b/taiga/importers/jira/common.py new file mode 100644 index 000000000..1a770922c --- /dev/null +++ b/taiga/importers/jira/common.py @@ -0,0 +1,768 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import requests +from urllib.parse import parse_qsl, quote_plus +from oauthlib.oauth1 import SIGNATURE_RSA + +from requests_oauthlib import OAuth1 +from django.core.files.base import ContentFile +from django.contrib.contenttypes.models import ContentType +from django.conf import settings + +from taiga.users.models import User +from taiga.projects.models import Points +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task +from taiga.projects.issues.models import Issue +from taiga.projects.milestones.models import Milestone +from taiga.projects.epics.models import Epic +from taiga.projects.attachments.models import Attachment +from taiga.projects.history.services import take_snapshot +from taiga.projects.history.services import (make_diff_from_dicts, + make_diff_values, + make_key_from_model_object, + get_typename_for_model_class, + FrozenDiff) +from taiga.projects.custom_attributes.models import (UserStoryCustomAttribute, + TaskCustomAttribute, + IssueCustomAttribute, + EpicCustomAttribute) +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.choices import HistoryType +from taiga.mdrender.service import render as mdrender +from taiga.importers import exceptions +from taiga.front.templatetags.functions import resolve as resolve_front_url + +EPIC_COLORS = { + "ghx-label-0": "#ffffff", + "ghx-label-1": "#815b3a", + "ghx-label-2": "#f79232", + "ghx-label-3": "#d39c3f", + "ghx-label-4": "#3b7fc4", + "ghx-label-5": "#4a6785", + "ghx-label-6": "#8eb021", + "ghx-label-7": "#ac707a", + "ghx-label-8": "#654982", + "ghx-label-9": "#f15c75", +} + + +def links_to_richtext(importer, issue, links): + richtext = "" + importing_project_key = issue['key'].split("-")[0] + for link in links: + if "inwardIssue" in link: + (project_key, issue_key) = link['inwardIssue']['key'].split("-") + action = link['type']['inward'] + elif "outwardIssue" in link: + (project_key, issue_key) = link['outwardIssue']['key'].split("-") + action = link['type']['outward'] + else: + continue + + if importing_project_key == project_key: + richtext += " * This item {} #{}\n".format(action, issue_key) + else: + url = importer._client.server + "/projects/{}/issues/{}-{}".format( + project_key, + project_key, + issue_key + ) + richtext += " * This item {} [{}-{}]({})\n".format(action, project_key, issue_key, url) + + for link in links: + if "object" in link: + richtext += " * [{}]({})\n".format( + link['object']['title'] or link['object']['url'], + link['object']['url'], + ) + + return richtext + + +class JiraClient: + def __init__(self, server, oauth): + self.server = server + self.api_url = server + "/rest/agile/1.0/{}" + self.main_api_url = server + "/rest/api/2/{}" + if oauth: + self.oauth = OAuth1( + oauth['consumer_key'], + signature_method=SIGNATURE_RSA, + rsa_key=oauth['key_cert'], + resource_owner_key=oauth['access_token'], + resource_owner_secret=oauth['access_token_secret'] + ) + else: + self.oauth = None + + def get(self, uri_path, query_params=None): + headers = { + 'Content-Type': "application/json" + } + if query_params is None: + query_params = {} + + if uri_path[0] == '/': + uri_path = uri_path[1:] + url = self.main_api_url.format(uri_path) + + response = requests.get(url, params=query_params, headers=headers, auth=self.oauth) + + if response.status_code == 401: + raise Exception("Unauthorized: %s at %s" % (response.text, url), response) + if response.status_code != 200: + raise Exception("Resource Unavailable: %s at %s" % (response.text, url), response) + + return response.json() + + def get_agile(self, uri_path, query_params=None): + headers = { + 'Content-Type': "application/json" + } + if query_params is None: + query_params = {} + + if uri_path[0] == '/': + uri_path = uri_path[1:] + url = self.api_url.format(uri_path) + + response = requests.get(url, params=query_params, headers=headers, auth=self.oauth) + + if response.status_code == 401: + raise Exception("Unauthorized: %s at %s" % (response.text, url), response) + if response.status_code != 200: + raise Exception("Resource Unavailable: %s at %s" % (response.text, url), response) + + return response.json() + + def raw_get(self, absolute_uri, query_params=None): + if query_params is None: + query_params = {} + + response = requests.get(absolute_uri, params=query_params, auth=self.oauth) + + if response.status_code == 401: + raise Exception("Unauthorized: %s at %s" % (response.text, absolute_uri), response) + if response.status_code != 200: + raise Exception("Resource Unavailable: %s at %s" % (response.text, absolute_uri), response) + + return response.content + + def get_issue_url(self, key): + (project_key, issue_key) = key.split("-") + return self.server + "/projects/{}/issues/{}".format(project_key, key) + + +class JiraImporterCommon: + def __init__(self, user, server, oauth): + self._user = user + self._client = JiraClient(server=server, oauth=oauth) + + def resolve_user_bindings(self, options): + def resolve_user(user_id): + if isinstance(user_id, User): + return user_id + try: + user = User.objects.get(id=user_id) + return user + except User.DoesNotExist: + return None + + options['users_bindings'] = {k: resolve_user(v) for k,v in options['users_bindings'].items() if v is not None} + + def list_users(self): + result = [] + projects = self._client.get('/project') + users = self._client.get("/user/assignable/multiProjectSearch", { + "username": "", + "projectKeys": list(map(lambda x: x['key'], projects)), + "maxResults": 1000, + }) + for user in users: + user_data = self._client.get("/user", { + "key": user['key'] + }) + result.append({ + "id": user_data['key'], + "full_name": user_data['displayName'], + "email": user_data['emailAddress'], + "avatar": user_data.get('avatarUrls', None) and user_data['avatarUrls'].get('48x48', None), + }) + return result + + def _import_comments(self, obj, issue, options): + users_bindings = options.get('users_bindings', {}) + offset = 0 + while True: + comments = self._client.get("/issue/{}/comment".format(issue['key']), {"startAt": offset}) + for comment in comments['comments']: + snapshot = take_snapshot( + obj, + comment=comment['body'], + user=users_bindings.get( + comment['author']['name'], + User(full_name=comment['author']['displayName']) + ), + delete=False + ) + HistoryEntry.objects.filter(id=snapshot.id).update(created_at=comment['created']) + + offset += len(comments['comments']) + if len(comments['comments']) <= comments['maxResults']: + break + + def _create_custom_fields(self, project): + custom_fields = [] + for model in [UserStoryCustomAttribute, TaskCustomAttribute, IssueCustomAttribute, EpicCustomAttribute]: + model.objects.create( + name="Due date", + description="Due date", + type="date", + order=1, + project=project + ) + model.objects.create( + name="Priority", + description="Priority", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Resolution", + description="Resolution", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Resolution date", + description="Resolution date", + type="date", + order=1, + project=project + ) + model.objects.create( + name="Environment", + description="Environment", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Components", + description="Components", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Affects Version/s", + description="Affects Version/s", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Fix Version/s", + description="Fix Version/s", + type="text", + order=1, + project=project + ) + model.objects.create( + name="Links", + description="Links", + type="richtext", + order=1, + project=project + ) + custom_fields.append({ + "history_name": "duedate", + "jira_field_name": "duedate", + "taiga_field_name": "Due date", + }) + custom_fields.append({ + "history_name": "priority", + "jira_field_name": "priority", + "taiga_field_name": "Priority", + "transform": lambda issue, obj: obj.get('name', None) + }) + custom_fields.append({ + "history_name": "resolution", + "jira_field_name": "resolution", + "taiga_field_name": "Resolution", + "transform": lambda issue, obj: obj.get('name', None) + }) + custom_fields.append({ + "history_name": "Resolution date", + "jira_field_name": "resolutiondate", + "taiga_field_name": "Resolution date", + }) + custom_fields.append({ + "history_name": "environment", + "jira_field_name": "environment", + "taiga_field_name": "Environment", + }) + custom_fields.append({ + "history_name": "Component", + "jira_field_name": "components", + "taiga_field_name": "Components", + "transform": lambda issue, obj: ", ".join([c.get('name', None) for c in obj]) + }) + custom_fields.append({ + "history_name": "Version", + "jira_field_name": "versions", + "taiga_field_name": "Affects Version/s", + "transform": lambda issue, obj: ", ".join([c.get('name', None) for c in obj]) + }) + custom_fields.append({ + "history_name": "Fix Version", + "jira_field_name": "fixVersions", + "taiga_field_name": "Fix Version/s", + "transform": lambda issue, obj: ", ".join([c.get('name', None) for c in obj]) + }) + custom_fields.append({ + "history_name": "Link", + "jira_field_name": "issuelinks", + "taiga_field_name": "Links", + "transform": lambda issue, obj: links_to_richtext(self, issue, obj) + }) + + greenhopper_fields = {} + for custom_field in self._client.get("/field"): + if custom_field['custom']: + if custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-sprint": + greenhopper_fields["sprint"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-epic-link": + greenhopper_fields["link"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-epic-status": + greenhopper_fields["status"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-epic-label": + greenhopper_fields["label"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-epic-color": + greenhopper_fields["color"] = custom_field['id'] + elif custom_field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-lexo-rank": + greenhopper_fields["rank"] = custom_field['id'] + elif ( + custom_field['name'] == "Story Points" and + custom_field['schema']['custom'] == 'com.atlassian.jira.plugin.system.customfieldtypes:float' + ): + greenhopper_fields["points"] = custom_field['id'] + else: + multiline_types = [ + "com.atlassian.jira.plugin.system.customfieldtypes:textarea" + ] + date_types = [ + "com.atlassian.jira.plugin.system.customfieldtypes:datepicker" + "com.atlassian.jira.plugin.system.customfieldtypes:datetime" + ] + if custom_field['schema']['custom'] in multiline_types: + field_type = "multiline" + elif custom_field['schema']['custom'] in date_types: + field_type = "date" + else: + field_type = "text" + + custom_field_data = { + "name": custom_field['name'][:64], + "description": custom_field['name'], + "type": field_type, + "order": 1, + "project": project + } + + UserStoryCustomAttribute.objects.get_or_create(**custom_field_data) + TaskCustomAttribute.objects.get_or_create(**custom_field_data) + IssueCustomAttribute.objects.get_or_create(**custom_field_data) + EpicCustomAttribute.objects.get_or_create(**custom_field_data) + + custom_fields.append({ + "history_name": custom_field['name'], + "jira_field_name": custom_field['id'], + "taiga_field_name": custom_field['name'][:64], + }) + + self.greenhopper_fields = greenhopper_fields + self.custom_fields = custom_fields + + def _import_to_custom_fields(self, obj, issue, options): + if isinstance(obj, Epic): + custom_att_manager = obj.project.epiccustomattributes + elif isinstance(obj, UserStory): + custom_att_manager = obj.project.userstorycustomattributes + elif isinstance(obj, Task): + custom_att_manager = obj.project.taskcustomattributes + elif isinstance(obj, Issue): + custom_att_manager = obj.project.issuecustomattributes + else: + raise NotImplementedError("Not implemented custom attributes for this object ({})".format(obj)) + + custom_attributes_values = {} + for custom_field in self.custom_fields: + data = issue['fields'].get(custom_field['jira_field_name'], None) + if data and "transform" in custom_field: + data = custom_field['transform'](issue, data) + + if data: + taiga_field = custom_att_manager.get(name=custom_field['taiga_field_name']) + custom_attributes_values[taiga_field.id] = data + + if custom_attributes_values != {}: + obj.custom_attributes_values.attributes_values = custom_attributes_values + obj.custom_attributes_values.save() + + def _import_attachments(self, obj, issue, options): + users_bindings = options.get('users_bindings', {}) + + for attachment in issue['fields']['attachment']: + try: + data = self._client.raw_get(attachment['content']) + att = Attachment( + owner=users_bindings.get(attachment['author']['name'], self._user), + project=obj.project, + content_type=ContentType.objects.get_for_model(obj), + object_id=obj.id, + name=attachment['filename'], + size=attachment['size'], + created_date=attachment['created'], + is_deprecated=False, + ) + att.attached_file.save(attachment['filename'], ContentFile(data), save=True) + except Exception: + print("ERROR getting attachment url {}".format(attachment['content'])) + + + def _import_changelog(self, project, obj, issue, options): + obj.cummulative_attachments = [] + for history in sorted(issue['changelog']['histories'], key=lambda h: h['created']): + self._import_history(project, obj, history, options) + + def _import_history(self, project, obj, history, options): + key = make_key_from_model_object(obj) + typename = get_typename_for_model_class(obj.__class__) + history_data = self._transform_history_data(project, obj, history, options) + if history_data is None: + return + + change_old = history_data['change_old'] + change_new = history_data['change_new'] + hist_type = history_data['hist_type'] + comment = history_data['comment'] + user = history_data['user'] + + diff = make_diff_from_dicts(change_old, change_new) + fdiff = FrozenDiff(key, diff, {}) + + values = make_diff_values(typename, fdiff) + values.update(history_data['update_values']) + + entry = HistoryEntry.objects.create( + user=user, + project_id=obj.project.id, + key=key, + type=hist_type, + snapshot=None, + diff=fdiff.diff, + values=values, + comment=comment, + comment_html=mdrender(obj.project, comment), + is_hidden=False, + is_snapshot=False, + ) + HistoryEntry.objects.filter(id=entry.id).update(created_at=history['created']) + return HistoryEntry.objects.get(id=entry.id) + + def _transform_history_data(self, project, obj, history, options): + users_bindings = options.get('users_bindings', {}) + + user = {"pk": None, "name": history.get('author', {}).get('displayName', None)} + taiga_user = users_bindings.get(history.get('author', {}).get('key', None), None) + if taiga_user: + user = {"pk": taiga_user.id, "name": taiga_user.get_full_name()} + + result = { + "change_old": {}, + "change_new": {}, + "update_values": {}, + "hist_type": HistoryType.change, + "comment": "", + "user": user + } + custom_fields_by_names = {f["history_name"]: f for f in self.custom_fields} + has_data = False + for history_item in history['items']: + if history_item['field'] == "Attachment": + result['change_old']["attachments"] = [] + for att in obj.cummulative_attachments: + result['change_old']["attachments"].append({ + "id": 0, + "filename": att + }) + + if history_item['from'] is not None: + try: + idx = obj.cummulative_attachments.index(history_item['fromString']) + obj.cummulative_attachments.pop(idx) + except ValueError: + print("ERROR: Removing attachment that doesn't exist in the history ({})".format(history_item['fromString'])) + if history_item['to'] is not None: + obj.cummulative_attachments.append(history_item['toString']) + + result['change_new']["attachments"] = [] + for att in obj.cummulative_attachments: + result['change_new']["attachments"].append({ + "id": 0, + "filename": att + }) + has_data = True + elif history_item['field'] == "description": + result['change_old']["description"] = history_item['fromString'] + result['change_new']["description"] = history_item['toString'] + result['change_old']["description_html"] = mdrender(obj.project, history_item['fromString'] or "") + result['change_new']["description_html"] = mdrender(obj.project, history_item['toString'] or "") + has_data = True + elif history_item['field'] == "Epic Link": + pass + elif history_item['field'] == "Workflow": + pass + elif history_item['field'] == "Link": + pass + elif history_item['field'] == "labels": + result['change_old']["tags"] = history_item['fromString'].split() + result['change_new']["tags"] = history_item['toString'].split() + has_data = True + elif history_item['field'] == "Rank": + pass + elif history_item['field'] == "RemoteIssueLink": + pass + elif history_item['field'] == "Sprint": + old_milestone = None + if history_item['fromString']: + try: + old_milestone = obj.project.milestones.get(name=history_item['fromString']).id + except Milestone.DoesNotExist: + old_milestone = -1 + + new_milestone = None + if history_item['toString']: + try: + new_milestone = obj.project.milestones.get(name=history_item['toString']).id + except Milestone.DoesNotExist: + new_milestone = -2 + + result['change_old']["milestone"] = old_milestone + result['change_new']["milestone"] = new_milestone + + if old_milestone == -1 or new_milestone == -2: + result['update_values']["milestone"] = {} + + if old_milestone == -1: + result['update_values']["milestone"]["-1"] = history_item['fromString'] + if new_milestone == -2: + result['update_values']["milestone"]["-2"] = history_item['toString'] + has_data = True + elif history_item['field'] == "status": + if isinstance(obj, Task): + try: + old_status = obj.project.task_statuses.get(name=history_item['fromString']).id + except Exception: + old_status = -1 + try: + new_status = obj.project.task_statuses.get(name=history_item['toString']).id + except Exception: + new_status = -2 + elif isinstance(obj, UserStory): + try: + old_status = obj.project.us_statuses.get(name=history_item['fromString']).id + except Exception: + old_status = -1 + try: + new_status = obj.project.us_statuses.get(name=history_item['toString']).id + except Exception: + new_status = -2 + elif isinstance(obj, Issue): + try: + old_status = obj.project.issue_statuses.get(name=history_item['fromString']).id + except Exception: + old_status = -1 + try: + new_status = obj.project.us_statuses.get(name=history_item['toString']).id + except Exception: + new_status = -2 + elif isinstance(obj, Epic): + try: + old_status = obj.project.epic_statuses.get(name=history_item['fromString']).id + except Exception: + old_status = -1 + try: + new_status = obj.project.epic_statuses.get(name=history_item['toString']).id + except Exception: + new_status = -2 + + if old_status == -1 or new_status == -2: + result['update_values']["status"] = {} + + if old_status == -1: + result['update_values']["status"]["-1"] = history_item['fromString'] + if new_status == -2: + result['update_values']["status"]["-2"] = history_item['toString'] + + result['change_old']["status"] = old_status + result['change_new']["status"] = new_status + has_data = True + elif history_item['field'] == "Story Points": + old_points = None + if history_item['fromString']: + estimation = float(history_item['fromString']) + (old_points, _) = Points.objects.get_or_create( + project=project, + value=estimation, + defaults={ + "name": str(estimation), + "order": estimation, + } + ) + old_points = old_points.id + new_points = None + if history_item['toString']: + estimation = float(history_item['toString']) + (new_points, _) = Points.objects.get_or_create( + project=project, + value=estimation, + defaults={ + "name": str(estimation), + "order": estimation, + } + ) + new_points = new_points.id + result['change_old']["points"] = {project.roles.get(slug="main").id: old_points} + result['change_new']["points"] = {project.roles.get(slug="main").id: new_points} + has_data = True + elif history_item['field'] == "summary": + result['change_old']["subject"] = history_item['fromString'] + result['change_new']["subject"] = history_item['toString'] + has_data = True + elif history_item['field'] == "Epic Color": + if isinstance(obj, Epic): + result['change_old']["color"] = EPIC_COLORS.get(history_item['fromString'], None) + result['change_new']["color"] = EPIC_COLORS.get(history_item['toString'], None) + Epic.objects.filter(id=obj.id).update( + color=EPIC_COLORS.get(history_item['toString'], "#999999") + ) + has_data = True + elif history_item['field'] == "assignee": + old_assigned_to = None + if history_item['from'] is not None: + old_assigned_to = users_bindings.get(history_item['from'], -1) + if old_assigned_to != -1: + old_assigned_to = old_assigned_to.id + + new_assigned_to = None + if history_item['to'] is not None: + new_assigned_to = users_bindings.get(history_item['to'], -2) + if new_assigned_to != -2: + new_assigned_to = new_assigned_to.id + + result['change_old']["assigned_to"] = old_assigned_to + result['change_new']["assigned_to"] = new_assigned_to + + if old_assigned_to == -1 or new_assigned_to == -2: + result['update_values']["users"] = {} + + if old_assigned_to == -1: + result['update_values']["users"]["-1"] = history_item['fromString'] + if new_assigned_to == -2: + result['update_values']["users"]["-2"] = history_item['toString'] + has_data = True + elif history_item['field'] in custom_fields_by_names: + custom_field = custom_fields_by_names[history_item['field']] + if isinstance(obj, Task): + field_obj = obj.project.taskcustomattributes.get(name=custom_field['taiga_field_name']) + elif isinstance(obj, UserStory): + field_obj = obj.project.userstorycustomattributes.get(name=custom_field['taiga_field_name']) + elif isinstance(obj, Issue): + field_obj = obj.project.issuecustomattributes.get(name=custom_field['taiga_field_name']) + elif isinstance(obj, Epic): + field_obj = obj.project.epiccustomattributes.get(name=custom_field['taiga_field_name']) + + result['change_old']["custom_attributes"] = [{ + "name": custom_field['taiga_field_name'], + "value": history_item['fromString'], + "id": field_obj.id + }] + result['change_new']["custom_attributes"] = [{ + "name": custom_field['taiga_field_name'], + "value": history_item['toString'], + "id": field_obj.id + }] + has_data = True + + if not has_data: + return None + + return result + + def _cleanup(self, project, options): + for epic_custom_field in project.epiccustomattributes.all(): + if project.epics.filter(custom_attributes_values__attributes_values__has_key=str(epic_custom_field.id)).count() == 0: + epic_custom_field.delete() + for us_custom_field in project.userstorycustomattributes.all(): + if project.user_stories.filter(custom_attributes_values__attributes_values__has_key=str(us_custom_field.id)).count() == 0: + us_custom_field.delete() + for task_custom_field in project.taskcustomattributes.all(): + if project.tasks.filter(custom_attributes_values__attributes_values__has_key=str(task_custom_field.id)).count() == 0: + task_custom_field.delete() + for issue_custom_field in project.issuecustomattributes.all(): + if project.issues.filter(custom_attributes_values__attributes_values__has_key=str(issue_custom_field.id)).count() == 0: + issue_custom_field.delete() + + @classmethod + def get_auth_url(cls, server, consumer_key, key_cert_data, verify=None): + if verify is None: + verify = server.startswith('https') + + callback_uri = resolve_front_url("project-import-jira", quote_plus(server)) + oauth = OAuth1(consumer_key, signature_method=SIGNATURE_RSA, rsa_key=key_cert_data, callback_uri=callback_uri) + + r = requests.post( + server + '/plugins/servlet/oauth/request-token', verify=verify, auth=oauth) + if r.status_code != 200: + raise exceptions.InvalidServiceConfiguration() + request = dict(parse_qsl(r.text)) + request_token = request['oauth_token'] + request_token_secret = request['oauth_token_secret'] + + return ( + request_token, + request_token_secret, + '{}/plugins/servlet/oauth/authorize?oauth_token={}'.format(server, request_token) + ) + + @classmethod + def get_access_token(cls, server, consumer_key, key_cert_data, request_token, request_token_secret, request_verifier, verify=False): + callback_uri = resolve_front_url("project-import-jira", quote_plus(server)) + oauth = OAuth1( + consumer_key, + signature_method=SIGNATURE_RSA, + callback_uri=callback_uri, + rsa_key=key_cert_data, + resource_owner_key=request_token, + resource_owner_secret=request_token_secret, + verifier=request_verifier, + ) + r = requests.post(server + '/plugins/servlet/oauth/access-token', verify=verify, auth=oauth) + access = dict(parse_qsl(r.text)) + + return { + 'access_token': access['oauth_token'], + 'access_token_secret': access['oauth_token_secret'], + 'consumer_key': consumer_key, + 'key_cert': key_cert_data + } diff --git a/taiga/importers/jira/normal.py b/taiga/importers/jira/normal.py new file mode 100644 index 000000000..39cccdd60 --- /dev/null +++ b/taiga/importers/jira/normal.py @@ -0,0 +1,440 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from collections import OrderedDict + +from django.template.defaultfilters import slugify +from taiga.projects.references.models import recalc_reference_counter +from taiga.projects.models import Project, ProjectTemplate, Points +from taiga.projects.userstories.models import UserStory, RolePoints +from taiga.projects.tasks.models import Task +from taiga.projects.issues.models import Issue +from taiga.projects.epics.models import Epic, RelatedUserStory +from taiga.projects.history.services import take_snapshot +from taiga.timeline.rebuilder import rebuild_timeline +from taiga.timeline.models import Timeline +from .common import JiraImporterCommon +from taiga.importers import services as import_service + + +class JiraNormalImporter(JiraImporterCommon): + def list_projects(self): + return [{"id": project['id'], + "name": project['name'], + "description": project['description'], + "is_private": True, + "importer_type": "normal"} for project in self._client.get('/project', {"expand": "description"})] + + def list_issue_types(self, project_id): + statuses = self._client.get("/project/{}/statuses".format(project_id)) + return statuses + + def import_project(self, project_id, options): + self.resolve_user_bindings(options) + project = self._import_project_data(project_id, options) + self._import_user_stories_data(project_id, project, options) + self._import_epics_data(project_id, project, options) + self._link_epics_with_user_stories(project_id, project, options) + self._import_issues_data(project_id, project, options) + self._cleanup(project, options) + Timeline.objects.filter(project=project).delete() + rebuild_timeline(None, None, project.id) + recalc_reference_counter(project) + return project + + def _import_project_data(self, project_id, options): + project = self._client.get("/project/{}".format(project_id)) + project_template = ProjectTemplate.objects.get(slug=options['template']) + + epic_statuses = OrderedDict() + for issue_type in options.get('types_bindings', {}).get("epic", []): + for status in issue_type['statuses']: + epic_statuses[status['name']] = status + + us_statuses = OrderedDict() + for issue_type in options.get('types_bindings', {}).get("us", []): + for status in issue_type['statuses']: + us_statuses[status['name']] = status + + task_statuses = OrderedDict() + for issue_type in options.get('types_bindings', {}).get("task", []): + for status in issue_type['statuses']: + task_statuses[status['name']] = status + + issue_statuses = OrderedDict() + for issue_type in options.get('types_bindings', {}).get("issue", []): + for status in issue_type['statuses']: + issue_statuses[status['name']] = status + + counter = 0 + if epic_statuses: + project_template.epic_statuses = [] + project_template.is_epics_activated = True + for epic_status in epic_statuses.values(): + project_template.epic_statuses.append({ + "name": epic_status['name'], + "slug": slugify(epic_status['name']), + "is_closed": False, + "color": "#999999", + "order": counter, + }) + counter += 1 + if epic_statuses: + project_template.default_options["epic_status"] = list(epic_statuses.values())[0]['name'] + + project_template.points = [{ + "value": None, + "name": "?", + "order": 0, + }] + + main_permissions = project_template.roles[0]['permissions'] + project_template.roles = [{ + "name": "Main", + "slug": "main", + "computable": True, + "permissions": main_permissions, + "order": 70, + }] + + counter = 0 + if us_statuses: + project_template.us_statuses = [] + for us_status in us_statuses.values(): + project_template.us_statuses.append({ + "name": us_status['name'], + "slug": slugify(us_status['name']), + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": counter, + }) + counter += 1 + if us_statuses: + project_template.default_options["us_status"] = list(us_statuses.values())[0]['name'] + + counter = 0 + if task_statuses: + project_template.task_statuses = [] + for task_status in task_statuses.values(): + project_template.task_statuses.append({ + "name": task_status['name'], + "slug": slugify(task_status['name']), + "is_closed": False, + "color": "#999999", + "order": counter, + }) + counter += 1 + if task_statuses: + project_template.default_options["task_status"] = list(task_statuses.values())[0]['name'] + + counter = 0 + if issue_statuses: + project_template.issue_statuses = [] + for issue_status in issue_statuses.values(): + project_template.issue_statuses.append({ + "name": issue_status['name'], + "slug": slugify(issue_status['name']), + "is_closed": False, + "color": "#999999", + "order": counter, + }) + counter += 1 + if issue_statuses: + project_template.default_options["issue_status"] = list(issue_statuses.values())[0]['name'] + + + main_permissions = project_template.roles[0]['permissions'] + project_template.roles = [{ + "name": "Main", + "slug": "main", + "computable": True, + "permissions": main_permissions, + "order": 70, + }] + + project = Project.objects.create( + name=options.get('name', None) or project['name'], + description=options.get('description', None) or project.get('description', ''), + owner=self._user, + creation_template=project_template, + is_private=options.get('is_private', False), + ) + + self._create_custom_fields(project) + import_service.create_memberships(options.get('users_bindings', {}), project, self._user, "main") + + return project + + def _import_user_stories_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + + types = options.get('types_bindings', {}).get("us", []) + for issue_type in types: + counter = 0 + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "project={} AND issuetype={}".format(project_id, issue_type['id']), + "startAt": offset, + "fields": "*all", + "expand": "changelog,attachment", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False) and 'url' in issue['fields']: + external_reference = ["jira", issue['fields']['url']] + + + us = UserStory.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.us_statuses.get(name=issue['fields']['status']['name']), + kanban_order=counter, + sprint_order=counter, + backlog_order=counter, + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + points_value = issue['fields'].get(self.greenhopper_fields.get('points', None), None) + if points_value: + (points, _) = Points.objects.get_or_create( + project=project, + value=points_value, + defaults={ + "name": str(points_value), + "order": points_value, + } + ) + RolePoints.objects.filter(user_story=us, role__slug="main").update(points_id=points.id) + else: + points = Points.objects.get(project=project, value__isnull=True) + RolePoints.objects.filter(user_story=us, role__slug="main").update(points_id=points.id) + + self._import_to_custom_fields(us, issue, options) + + us.ref = issue['key'].split("-")[1] + UserStory.objects.filter(id=us.id).update( + ref=us.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(us, comment="", user=None, delete=False) + self._import_subtasks(project_id, project, us, issue, options) + self._import_comments(us, issue, options) + self._import_attachments(us, issue, options) + self._import_changelog(project, us, issue, options) + counter += 1 + + if len(issues['issues']) < issues['maxResults']: + break + + def _import_subtasks(self, project_id, project, us, issue, options): + users_bindings = options.get('users_bindings', {}) + + if len(issue['fields']['subtasks']) == 0: + return + + counter = 0 + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "parent={}".format(issue['key']), + "startAt": offset, + "fields": "*all", + "expand": "changelog,attachment", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False) and 'url' in issue['fields']: + external_reference = ["jira", issue['fields']['url']] + + task = Task.objects.create( + user_story=us, + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.task_statuses.get(name=issue['fields']['status']['name']), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + self._import_to_custom_fields(task, issue, options) + + task.ref = issue['key'].split("-")[1] + Task.objects.filter(id=task.id).update( + ref=task.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(task, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(task, issue, options) + self._import_attachments(task, issue, options) + self._import_changelog(project, task, issue, options) + counter += 1 + if len(issues['issues']) < issues['maxResults']: + break + + def _import_issues_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + + types = options.get('types_bindings', {}).get("issue", []) + for issue_type in types: + counter = 0 + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "project={} AND issuetype={}".format(project_id, issue_type['id']), + "startAt": offset, + "fields": "*all", + "expand": "changelog,attachment", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False) and 'url' in issue['fields']: + external_reference = ["jira", issue['fields']['url']] + + taiga_issue = Issue.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.issue_statuses.get(name=issue['fields']['status']['name']), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + self._import_to_custom_fields(taiga_issue, issue, options) + + taiga_issue.ref = issue['key'].split("-")[1] + Issue.objects.filter(id=taiga_issue.id).update( + ref=taiga_issue.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(taiga_issue, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(taiga_issue, issue, options) + self._import_attachments(taiga_issue, issue, options) + self._import_changelog(project, taiga_issue, issue, options) + counter += 1 + + if len(issues['issues']) < issues['maxResults']: + break + + def _import_epics_data(self, project_id, project, options): + users_bindings = options.get('users_bindings', {}) + + types = options.get('types_bindings', {}).get("epic", []) + for issue_type in types: + counter = 0 + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "project={} AND issuetype={}".format(project_id, issue_type['id']), + "startAt": offset, + "fields": "*all", + "expand": "changelog,attachment", + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + issue['fields']['issuelinks'] += self._client.get("/issue/{}/remotelink".format(issue['key'])) + assigned_to = users_bindings.get(issue['fields']['assignee']['key'] if issue['fields']['assignee'] else None, None) + owner = users_bindings.get(issue['fields']['creator']['key'] if issue['fields']['creator'] else None, self._user) + + external_reference = None + if options.get('keep_external_reference', False) and 'url' in issue['fields']: + external_reference = ["jira", issue['fields']['url']] + + epic = Epic.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.epic_statuses.get(name=issue['fields']['status']['name']), + subject=issue['fields']['summary'], + description=issue['fields']['description'] or '', + epics_order=counter, + tags=issue['fields']['labels'], + external_reference=external_reference, + ) + + self._import_to_custom_fields(epic, issue, options) + + epic.ref = issue['key'].split("-")[1] + Epic.objects.filter(id=epic.id).update( + ref=epic.ref, + modified_date=issue['fields']['updated'], + created_date=issue['fields']['created'] + ) + take_snapshot(epic, comment="", user=None, delete=False) + for subtask in issue['fields']['subtasks']: + print("WARNING: Ignoring subtask {} because parent isn't a User Story".format(subtask['key'])) + self._import_comments(epic, issue, options) + self._import_attachments(epic, issue, options) + issue_with_changelog = self._client.get("/issue/{}".format(issue['key']), { + "expand": "changelog" + }) + self._import_changelog(project, epic, issue_with_changelog, options) + counter += 1 + + if len(issues['issues']) < issues['maxResults']: + break + + def _link_epics_with_user_stories(self, project_id, project, options): + types = options.get('types_bindings', {}).get("us", []) + for issue_type in types: + offset = 0 + while True: + issues = self._client.get("/search", { + "jql": "project={} AND issuetype={}".format(project_id, issue_type['id']), + "startAt": offset + }) + offset += issues['maxResults'] + + for issue in issues['issues']: + epic_key = issue['fields'][self.greenhopper_fields['link']] + if epic_key: + epic = project.epics.get(ref=int(epic_key.split("-")[1])) + us = project.user_stories.get(ref=int(issue['key'].split("-")[1])) + RelatedUserStory.objects.create( + user_story=us, + epic=epic, + order=1 + ) + + if len(issues['issues']) < issues['maxResults']: + break diff --git a/taiga/importers/jira/tasks.py b/taiga/importers/jira/tasks.py new file mode 100644 index 000000000..cffc0c5c7 --- /dev/null +++ b/taiga/importers/jira/tasks.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import logging +import sys + +from django.utils.translation import gettext as _ + +from taiga.base.mails import mail_builder +from taiga.users.models import User +from taiga.celery import app +from .normal import JiraNormalImporter +from .agile import JiraAgileImporter + +logger = logging.getLogger('taiga.importers.jira') + + +@app.task(bind=True) +def import_project(self, user_id, url, token, project_id, options, importer_type): + user = User.objects.get(id=user_id) + + if importer_type == "agile": + importer = JiraAgileImporter(user, url, token) + else: + importer = JiraNormalImporter(user, url, token) + + try: + project = importer.import_project(project_id, options) + except Exception as e: + # Error + ctx = { + "user": user, + "error_subject": _("Error importing Jira project"), + "error_message": _("Error importing Jira project"), + "project": project_id, + "exception": e + } + email = mail_builder.importer_import_error(user, ctx) + email.send() + logger.error('Error importing Jira project %s (by %s)', project_id, user, exc_info=sys.exc_info()) + else: + ctx = { + "project": project, + "user": user, + } + email = mail_builder.jira_import_success(user, ctx) + email.send() diff --git a/taiga/importers/management/__init__.py b/taiga/importers/management/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/importers/management/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/importers/management/commands/__init__.py b/taiga/importers/management/commands/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/importers/management/commands/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/importers/management/commands/import_from_asana.py b/taiga/importers/management/commands/import_from_asana.py new file mode 100644 index 000000000..f20437348 --- /dev/null +++ b/taiga/importers/management/commands/import_from_asana.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from django.conf import settings +from django.db.models import Q + +from taiga.importers.asana.importer import AsanaImporter +from taiga.users.models import User + +import json + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument('--token', dest="token", type=str, + help='Auth token') + parser.add_argument('--project-id', dest="project_id", type=str, + help='Project ID or full name (ex: taigaio/taiga-back)') + parser.add_argument('--template', dest='template', default="kanban", + help='template to use: scrum or kanban (default kanban)') + parser.add_argument('--type', dest='type', default="user_stories", + help='type of object to use: user_stories or issues (default user_stories)') + parser.add_argument('--ask-for-users', dest='ask_for_users', const=True, + action="store_const", default=False, + help='Import closed data') + parser.add_argument('--keep-external-reference', dest='keep_external_reference', const=True, + action="store_const", default=False, + help='Store external reference of imported data') + + def handle(self, *args, **options): + admin = User.objects.get(username="admin") + + if options.get('token', None): + token = json.loads(options.get('token')) + else: + url = AsanaImporter.get_auth_url( + settings.IMPORTERS.get('asana', {}).get('app_id', None), + settings.IMPORTERS.get('asana', {}).get('app_secret', None), + settings.IMPORTERS.get('asana', {}).get('callback_url', None) + ) + print("Go to here and come with your code (in the redirected url): {}".format(url)) + code = input("Code: ") + access_data = AsanaImporter.get_access_token( + code, + settings.IMPORTERS.get('asana', {}).get('app_id', None), + settings.IMPORTERS.get('asana', {}).get('app_secret', None), + settings.IMPORTERS.get('asana', {}).get('callback_url', None) + ) + token = access_data + + importer = AsanaImporter(admin, token) + + if options.get('project_id', None): + project_id = options.get('project_id') + else: + print("Select the project to import:") + for project in importer.list_projects(): + print("- {}: {}".format(project['id'], project['name'])) + project_id = input("Project id: ") + + users_bindings = {} + if options.get('ask_for_users', None): + print("Add the username or email for next asana users:") + + for user in importer.list_users(project_id): + while True: + if user['detected_user'] is not None: + print("User automatically detected: {} as {}".format(user['full_name'], user['detected_user'])) + users_bindings[user['id']] = user['detected_user'] + break + + if not options.get('ask_for_users', False): + break + + username_or_email = input("{}: ".format(user['full_name'] or user['username'])) + if username_or_email == "": + break + try: + users_bindings[user['id']] = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email)) + break + except User.DoesNotExist: + print("ERROR: Invalid username or email") + + options = { + "template": options.get('template'), + "type": options.get('type'), + "users_bindings": users_bindings, + "keep_external_reference": options.get('keep_external_reference') + } + + importer.import_project(project_id, options) diff --git a/taiga/importers/management/commands/import_from_github.py b/taiga/importers/management/commands/import_from_github.py new file mode 100644 index 000000000..a776a9507 --- /dev/null +++ b/taiga/importers/management/commands/import_from_github.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from django.conf import settings +from django.db.models import Q + +from taiga.importers.github.importer import GithubImporter +from taiga.users.models import User + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument('--token', dest="token", type=str, + help='Auth token') + parser.add_argument('--project-id', dest="project_id", type=str, + help='Project ID or full name (ex: taigaio/taiga-back)') + parser.add_argument('--template', dest='template', default="kanban", + help='template to use: scrum or kanban (default kanban)') + parser.add_argument('--type', dest='type', default="user_stories", + help='type of object to use: user_stories or issues (default user_stories)') + parser.add_argument('--ask-for-users', dest='ask_for_users', const=True, + action="store_const", default=False, + help='Import closed data') + parser.add_argument('--keep-external-reference', dest='keep_external_reference', const=True, + action="store_const", default=False, + help='Store external reference of imported data') + + def handle(self, *args, **options): + admin = User.objects.get(username="admin") + + if options.get('token', None): + token = options.get('token') + else: + url = GithubImporter.get_auth_url( + settings.IMPORTERS.get('github', {}).get('client_id', None) + ) + print("Go to here and come with your code (in the redirected url): {}".format(url)) + code = input("Code: ") + access_data = GithubImporter.get_access_token( + settings.IMPORTERS.get('github', {}).get('client_id', None), + settings.IMPORTERS.get('github', {}).get('client_secret', None), + code + ) + token = access_data + + importer = GithubImporter(admin, token) + + if options.get('project_id', None): + project_id = options.get('project_id') + else: + print("Select the project to import:") + for project in importer.list_projects(): + print("- {}: {}".format(project['id'], project['name'])) + project_id = input("Project id: ") + + users_bindings = {} + if options.get('ask_for_users', None): + print("Add the username or email for next github users:") + + for user in importer.list_users(project_id): + while True: + if user['detected_user'] is not None: + print("User automatically detected: {} as {}".format(user['full_name'], user['detected_user'])) + users_bindings[user['id']] = user['detected_user'] + break + + if not options.get('ask_for_users', False): + break + + username_or_email = input("{}: ".format(user['full_name'] or user['username'])) + if username_or_email == "": + break + try: + users_bindings[user['id']] = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email)) + break + except User.DoesNotExist: + print("ERROR: Invalid username or email") + + options = { + "template": options.get('template'), + "type": options.get('type'), + "users_bindings": users_bindings, + "keep_external_reference": options.get('keep_external_reference') + } + + importer.import_project(project_id, options) diff --git a/taiga/importers/management/commands/import_from_jira.py b/taiga/importers/management/commands/import_from_jira.py new file mode 100644 index 000000000..288cc8313 --- /dev/null +++ b/taiga/importers/management/commands/import_from_jira.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from django.db.models import Q +from django.conf import settings + +from taiga.importers.jira.agile import JiraAgileImporter +from taiga.importers.jira.normal import JiraNormalImporter +from taiga.users.models import User + +import json + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument('--token', dest="token", type=str, + help='Auth token') + parser.add_argument('--server', dest="server", type=str, + help='Server address (default: https://jira.atlassian.com)', + default="https://jira.atlassian.com") + parser.add_argument('--project-id', dest="project_id", type=str, + help='Project ID or full name (ex: taigaio/taiga-back)') + parser.add_argument('--project-type', dest="project_type", type=str, + help='Project type in jira: project or board') + parser.add_argument('--template', dest='template', default="scrum", + help='template to use: scrum or scrum (default scrum)') + parser.add_argument('--ask-for-users', dest='ask_for_users', const=True, + action="store_const", default=False, + help='Import closed data') + parser.add_argument('--closed-data', dest='closed_data', const=True, + action="store_const", default=False, + help='Import closed data') + parser.add_argument('--keep-external-reference', dest='keep_external_reference', const=True, + action="store_const", default=False, + help='Store external reference of imported data') + + def handle(self, *args, **options): + admin = User.objects.get(username="admin") + server = options.get("server") + + if options.get('token', None) == "anon": + token = None + elif options.get('token', None): + token = json.loads(options.get('token')) + else: + (rtoken, rtoken_secret, url) = JiraNormalImporter.get_auth_url( + server, + settings.IMPORTERS.get('jira', {}).get('consumer_key', None), + settings.IMPORTERS.get('jira', {}).get('cert', None), + True + ) + print(url) + input("Go to the url, allow the user and get back and press enter") + token = JiraNormalImporter.get_access_token( + server, + settings.IMPORTERS.get('jira', {}).get('consumer_key', None), + settings.IMPORTERS.get('jira', {}).get('cert', None), + rtoken, + rtoken_secret, + True + ) + print("Auth token: {}".format(json.dumps(token))) + + + if options.get('project_type', None) is None: + print("Select the type of project to import (project or board): ") + project_type = input("Project type: ") + else: + project_type = options.get('project_type') + + if project_type not in ["project", "board"]: + print("ERROR: Bad project type.") + return + + if project_type == "project": + importer = JiraNormalImporter(admin, server, token) + else: + importer = JiraAgileImporter(admin, server, token) + + if options.get('project_id', None): + project_id = options.get('project_id') + else: + print("Select the project to import:") + for project in importer.list_projects(): + print("- {}: {}".format(project['id'], project['name'])) + project_id = input("Project id or key: ") + + users_bindings = {} + if options.get('ask_for_users', None): + print("Add the username or email for next jira users:") + for user in importer.list_users(): + try: + users_bindings[user['key']] = User.objects.get(Q(email=user['email'])) + break + except User.DoesNotExist: + pass + + while True: + username_or_email = input("{}: ".format(user['full_name'])) + if username_or_email == "": + break + try: + users_bindings[user['key']] = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email)) + break + except User.DoesNotExist: + print("ERROR: Invalid username or email") + + options = { + "template": options.get('template'), + "import_closed_data": options.get("closed_data", False), + "users_bindings": users_bindings, + "keep_external_reference": options.get('keep_external_reference'), + } + + if project_type == "project": + print("Bind jira issue types to (epic, us, issue)") + types_bindings = { + "epic": [], + "us": [], + "task": [], + "issue": [], + } + + for issue_type in importer.list_issue_types(project_id): + while True: + if issue_type['subtask']: + types_bindings['task'].append(issue_type) + break + + taiga_type = input("{}: ".format(issue_type['name'])) + if taiga_type not in ['epic', 'us', 'issue']: + print("use a valid taiga type (epic, us, issue)") + continue + + types_bindings[taiga_type].append(issue_type) + break + options["types_bindings"] = types_bindings + + importer.import_project(project_id, options) diff --git a/taiga/importers/management/commands/import_from_pivotal.py b/taiga/importers/management/commands/import_from_pivotal.py new file mode 100644 index 000000000..9cfb518d8 --- /dev/null +++ b/taiga/importers/management/commands/import_from_pivotal.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from django.db.models import Q + +from taiga.importers.pivotal.importer import PivotalImporter +from taiga.users.models import User + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument('--token', dest="token", type=str, + help='Auth token') + parser.add_argument('--project-id', dest="project_id", type=str, + help='Project ID or full name (ex: taigaio/taiga-back)') + parser.add_argument('--template', dest='template', default="scrum", + help='template to use: scrum or scrum (default scrum)') + parser.add_argument('--ask-for-users', dest='ask_for_users', const=True, + action="store_const", default=False, + help='Import closed data') + parser.add_argument('--closed-data', dest='closed_data', const=True, + action="store_const", default=False, + help='Import closed data') + parser.add_argument('--keep-external-reference', dest='keep_external_reference', const=True, + action="store_const", default=False, + help='Store external reference of imported data') + + def handle(self, *args, **options): + admin = User.objects.get(username="admin") + + if options.get('token', None): + token = options.get('token') + else: + print("You need a user token") + return + + importer = PivotalImporter(admin, token) + + if options.get('project_id', None): + project_id = options.get('project_id') + else: + print("Select the project to import:") + for project in importer.list_projects(): + print("- {}: {}".format(project['project_id'], project['project_name'])) + project_id = input("Project id: ") + + users_bindings = {} + if options.get('ask_for_users', None): + print("Add the username or email for next pivotal users:") + for user in importer.list_users(project_id): + try: + users_bindings[user['id']] = User.objects.get(Q(email=user['person'].get('email', "not-valid"))) + break + except User.DoesNotExist: + pass + + while True: + username_or_email = input("{}: ".format(user['person']['name'])) + if username_or_email == "": + break + try: + users_bindings[user['id']] = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email)) + break + except User.DoesNotExist: + print("ERROR: Invalid username or email") + + options = { + "template": options.get('template'), + "import_closed_data": options.get("closed_data", False), + "users_bindings": users_bindings, + "keep_external_reference": options.get('keep_external_reference') + } + importer.import_project(project_id, options) diff --git a/taiga/importers/management/commands/import_from_trello.py b/taiga/importers/management/commands/import_from_trello.py new file mode 100644 index 000000000..bca50966d --- /dev/null +++ b/taiga/importers/management/commands/import_from_trello.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from django.db.models import Q + +from taiga.importers.trello.importer import TrelloImporter +from taiga.users.models import User + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument('--token', dest="token", type=str, + help='Auth token') + parser.add_argument('--project-id', dest="project_id", type=str, + help='Project ID or full name (ex: taigaio/taiga-back)') + parser.add_argument('--template', dest='template', default="kanban", + help='template to use: scrum or kanban (default kanban)') + parser.add_argument('--ask-for-users', dest='ask_for_users', const=True, + action="store_const", default=False, + help='Import closed data') + parser.add_argument('--closed-data', dest='closed_data', const=True, + action="store_const", default=False, + help='Import closed data') + parser.add_argument('--keep-external-reference', dest='keep_external_reference', const=True, + action="store_const", default=False, + help='Store external reference of imported data') + + def handle(self, *args, **options): + admin = User.objects.get(username="admin") + if options.get('token', None): + token = options.get('token') + else: + (oauth_token, oauth_token_secret, url) = TrelloImporter.get_auth_url() + print("Go to here and come with your token: {}".format(url)) + oauth_verifier = input("Code: ") + access_data = TrelloImporter.get_access_token(oauth_token, oauth_token_secret, oauth_verifier) + token = access_data['oauth_token'] + print("Access token: {}".format(token)) + importer = TrelloImporter(admin, token) + + if options.get('project_id', None): + project_id = options.get('project_id') + else: + print("Select the project to import:") + for project in importer.list_projects(): + print("- {}: {}".format(project['id'], project['name'])) + project_id = input("Project id: ") + + users_bindings = {} + if options.get('ask_for_users', None): + print("Add the username or email for next trello users:") + for user in importer.list_users(project_id): + while True: + username_or_email = input("{}: ".format(user['fullName'])) + if username_or_email == "": + break + try: + users_bindings[user['id']] = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email)) + break + except User.DoesNotExist: + print("ERROR: Invalid username or email") + + options = { + "template": options.get('template'), + "import_closed_data": options.get("closed_data", False), + "users_bindings": users_bindings, + "keep_external_reference": options.get('keep_external_reference') + } + importer.import_project(project_id, options) diff --git a/taiga/importers/permissions.py b/taiga/importers/permissions.py new file mode 100644 index 000000000..8ebf0716b --- /dev/null +++ b/taiga/importers/permissions.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission, IsAuthenticated + + +class ImporterPermission(TaigaResourcePermission): + enough_perms = IsAuthenticated() + global_perms = None + auth_url_perms = IsAuthenticated() + authorize_perms = IsAuthenticated() + list_users_perms = IsAuthenticated() + list_projects_perms = IsAuthenticated() + import_project_perms = IsAuthenticated() diff --git a/taiga/importers/pivotal/api.py b/taiga/importers/pivotal/api.py new file mode 100644 index 000000000..57a6122c7 --- /dev/null +++ b/taiga/importers/pivotal/api.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ +from django.conf import settings + +from taiga.base.api import viewsets +from taiga.base import response +from taiga.base import exceptions as exc +from taiga.base.decorators import list_route +from taiga.users.models import AuthData, User +from taiga.users.services import get_user_photo_url +from taiga.users.gravatar import get_user_gravatar_id + +from taiga.importers import permissions +from .importer import PivotalImporter +from . import tasks + + +class PivotalImporterViewSet(viewsets.ViewSet): + permission_classes = (permissions.ImporterPermission,) + + @list_route(methods=["POST"]) + def list_users(self, request, *args, **kwargs): + self.check_permissions(request, "list_users", None) + + token = request.DATA.get('token', None) + project_id = request.DATA.get('project', None) + + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + importer = PivotalImporter(request.user, token) + users = importer.list_users(project_id) + for user in users: + user['user'] = None + if not user['email']: + continue + + try: + taiga_user = User.objects.get(email=user['email']) + except User.DoesNotExist: + continue + + user['user'] = { + 'id': taiga_user.id, + 'full_name': taiga_user.get_full_name(), + 'gravatar_id': get_user_gravatar_id(taiga_user), + 'photo': get_user_photo_url(taiga_user), + } + return response.Ok(users) + + @list_route(methods=["POST"]) + def list_projects(self, request, *args, **kwargs): + self.check_permissions(request, "list_projects", None) + token = request.DATA.get('token', None) + importer = PivotalImporter(request.user, token) + projects = importer.list_projects() + return response.Ok(projects) + + @list_route(methods=["POST"]) + def import_project(self, request, *args, **kwargs): + self.check_permissions(request, "import_project", None) + + token = request.DATA.get('token', None) + project_id = request.DATA.get('project', None) + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + options = { + "template": request.DATA.get('template', "kanban"), + "users_bindings": request.DATA.get("users_bindings", {}), + "keep_external_reference": request.DATA.get("keep_external_reference", False), + "is_private": request.DATA.get("is_private", False), + } + + if settings.CELERY_ENABLED: + task = tasks.import_project.delay(request.user.id, token, project_id, options) + return response.Accepted({"pivotal_import_id": task.id}) + + importer = PivotalImporter(request.user, token) + project = importer.import_project(project_id, options) + project_data = { + "slug": project.slug, + "my_permissions": ["view_us"], + "is_backlog_activated": project.is_backlog_activated, + "is_kanban_activated": project.is_kanban_activated, + } + + return response.Ok(project_data) + + @list_route(methods=["GET"]) + def auth_url(self, request, *args, **kwargs): + self.check_permissions(request, "auth_url", None) + + (oauth_token, oauth_secret, url) = PivotalImporter.get_auth_url() + + (auth_data, created) = AuthData.objects.get_or_create( + user=request.user, + key="pivotal-oauth", + defaults={ + "value": "", + "extra": {}, + } + ) + auth_data.extra = { + "oauth_token": oauth_token, + "oauth_secret": oauth_secret, + } + auth_data.save() + + return response.Ok({"url": url}) + + @list_route(methods=["POST"]) + def authorize(self, request, *args, **kwargs): + self.check_permissions(request, "authorize", None) + + try: + oauth_data = request.user.auth_data.get(key="pivotal-oauth") + oauth_token = oauth_data.extra['oauth_token'] + oauth_secret = oauth_data.extra['oauth_secret'] + oauth_verifier = request.DATA.get('code') + oauth_data.delete() + pivotal_token = PivotalImporter.get_access_token(oauth_token, oauth_secret, oauth_verifier)['oauth_token'] + except Exception as e: + raise exc.WrongArguments(_("Invalid or expired auth token")) + + return response.Ok({ + "token": pivotal_token + }) diff --git a/taiga/importers/pivotal/importer.py b/taiga/importers/pivotal/importer.py new file mode 100644 index 000000000..c7257f9ff --- /dev/null +++ b/taiga/importers/pivotal/importer.py @@ -0,0 +1,709 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.files.base import ContentFile +from django.contrib.contenttypes.models import ContentType +import requests + +from taiga.users.models import User +from taiga.projects.references.models import recalc_reference_counter +from taiga.projects.models import Project, ProjectTemplate, Membership, Points +from taiga.projects.userstories.models import UserStory, RolePoints +from taiga.projects.tasks.models import Task +from taiga.projects.milestones.models import Milestone +from taiga.projects.epics.models import Epic, RelatedUserStory +from taiga.projects.attachments.models import Attachment +from taiga.projects.history.services import take_snapshot +from taiga.projects.history.services import (make_diff_from_dicts, + make_diff_values, + make_key_from_model_object, + get_typename_for_model_class, + FrozenDiff) +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.choices import HistoryType +from taiga.projects.custom_attributes.models import UserStoryCustomAttribute +from taiga.mdrender.service import render as mdrender +from taiga.timeline.rebuilder import rebuild_timeline +from taiga.timeline.models import Timeline + + +class PivotalClient: + def __init__(self, token): + self.api_url = "https://www.pivotaltracker.com/services/v5/{}" + self.token = token + self.me = self.get('/me') + + def get(self, uri_path, query_params=None): + headers = { + 'X-TrackerToken': self.token + } + if query_params is None: + query_params = {} + + if uri_path[0] == '/': + uri_path = uri_path[1:] + url = self.api_url.format(uri_path) + + response = requests.get(url, params=query_params, headers=headers) + + if response.status_code == 401: + raise Exception("Unauthorized: %s at %s" % (response.text, url), response) + if response.status_code != 200: + raise Exception("Resource Unavailable: %s at %s" % (response.text, url), response) + + return response.json() + + def get_attachment(self, attachment_id): + headers = { + 'X-TrackerToken': self.token + } + url = "https://www.pivotaltracker.com/file_attachments/{}/download".format(attachment_id) + response = requests.get(url, headers=headers) + return response.content + + +class PivotalImporter: + def __init__(self, user, token): + self._user = user + self._client = PivotalClient(token=token) + + def list_projects(self): + return self._client.me['projects'] + + def list_users(self, project_id): + return self._client.get("/projects/{}/memberships".format(project_id)) + + def import_project(self, project_id, options={"template": "scrum", "users_bindings": {}, "keep_external_reference": False}): + (project, project_data) = self._import_project_data(project_id, options) + self._import_epics_data(project_data, project, options) + self._import_user_stories_data(project_data, project, options) + Timeline.objects.filter(project=project).delete() + rebuild_timeline(None, None, project.id) + recalc_reference_counter(project) + + def _import_project_data(self, project_id, options): + project_data = self._client.get( + "/projects/{}".format(project_id), + { + "fields": ",".join([ + "point_scale", + "name", + "description", + "labels(name)", + ]) + } + ) + project_data['iterations'] = self._client.get( + "/projects/{}/iterations".format(project_id), + { + "fields": ",".join([ + "number", + "start", + "finish", + "stories", + ]) + } + ) + project_data['epics'] = self._client.get( + "/projects/{}/epics".format(project_data['id']), + { + "fields": ",".join([ + "name", + "label", + "description", + "comments(text,file_attachments,google_attachments,person,created_at)", + "follower_ids", + "created_at", + "updated_at", + "url", + ]) + } + ) + + project_template = ProjectTemplate.objects.get(slug=options['template']) + project_template.is_epics_activated = True + project_template.us_statuses = [] + project_template.points = [{ + "value": None, + "name": "?", + "order": 1, + }] + + counter = 2 + for points in project_data['point_scale'].split(","): + project_template.points.append({ + "value": int(points), + "name": points, + "order": counter + }) + counter += 1 + + project_template.us_statuses.append({ + "name": "Unscheduled", + "slug": "unscheduled", + "is_closed": True, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": 1, + }) + project_template.us_statuses.append({ + "name": "Unstarted", + "slug": "unstarted", + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": 2, + }) + project_template.us_statuses.append({ + "name": "Planned", + "slug": "planned", + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": 3, + }) + project_template.us_statuses.append({ + "name": "Started", + "slug": "started", + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": 4, + }) + project_template.us_statuses.append({ + "name": "Finished", + "slug": "finished", + "is_closed": False, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": 5, + }) + project_template.us_statuses.append({ + "name": "Delivered", + "slug": "delivered", + "is_closed": True, + "is_archived": False, + "color": "#999999", + "wip_limit": None, + "order": 6, + }) + project_template.us_statuses.append({ + "name": "Rejected", + "slug": "rejected", + "is_closed": True, + "is_archived": True, + "color": "#999999", + "wip_limit": None, + "order": 7, + }) + project_template.us_statuses.append({ + "name": "Accepted", + "slug": "accepted", + "is_closed": True, + "is_archived": True, + "color": "#999999", + "wip_limit": None, + "order": 8, + }) + project_template.default_options["us_status"] = "Unscheduled" + + project_template.task_statuses = [] + project_template.task_statuses.append({ + "name": "Incomplete", + "slug": "incomplete", + "is_closed": False, + "color": "#ff8a84", + "order": 1, + }) + project_template.task_statuses.append({ + "name": "Complete", + "slug": "complete", + "is_closed": True, + "color": "#669900", + "order": 2, + }) + project_template.default_options["task_status"] = "Incomplete" + + main_permissions = project_template.roles[0]['permissions'] + project_template.roles = [{ + "name": "Main", + "slug": "main", + "computable": True, + "permissions": main_permissions, + "order": 70, + }] + + tags_colors = [] + for label in project_data['labels']: + name = label['name'].lower() + tags_colors.append([name, None]) + + project = Project.objects.create( + name=project_data['name'], + description=project_data.get('description', ''), + owner=self._user, + tags_colors=tags_colors, + creation_template=project_template + ) + + UserStoryCustomAttribute.objects.create( + name="Due date", + description="Due date", + type="date", + order=1, + project=project + ) + UserStoryCustomAttribute.objects.create( + name="Type", + description="Story type", + type="text", + order=2, + project=project + ) + for user in options.get('users_bindings', {}).values(): + if user != self._user: + Membership.objects.get_or_create( + user=user, + project=project, + role=project.get_roles().get(slug="main"), + is_admin=False, + ) + + for iteration in project_data['iterations']: + milestone = Milestone.objects.create( + name="Sprint {}".format(iteration['number']), + slug="sprint-{}".format(iteration['number']), + owner=self._user, + project=project, + estimated_start=iteration['start'][:10], + estimated_finish=iteration['finish'][:10], + ) + Milestone.objects.filter(id=milestone.id).update( + created_date=iteration['start'], + modified_date=iteration['start'], + ) + return (project, project_data) + + def _import_user_stories_data(self, project_data, project, options): + users_bindings = options.get('users_bindings', {}) + epics = {e['label']['id']: e for e in project_data['epics']} + due_date_field = project.userstorycustomattributes.get(name="Due date") + story_type_field = project.userstorycustomattributes.get(name="Type") + story_milestone_binding = {} + for iteration in project_data['iterations']: + for story in iteration['stories']: + story_milestone_binding[story['id']] = Milestone.objects.get( + project=project, + slug="sprint-{}".format(iteration['number']) + ) + + counter = 0 + offset = 0 + while True: + stories = self._client.get("/projects/{}/stories".format(project_data['id']), { + "envelope": "true", + "limit": 300, + "offset": offset, + "fields": ",".join([ + "name", + "description", + "estimate", + "story_type", + "current_state", + "deadline", + "requested_by_id", + "owner_ids", + "labels(id,name)", + "comments(text,file_attachments,google_attachments,person,created_at)", + "tasks(id,description,position,complete,created_at,updated_at)", + "follower_ids", + "created_at", + "updated_at", + "url", + ])}) + offset += 300 + for story in stories['data']: + tags = [] + for label in story['labels']: + tags.append(label['name']) + + assigned_to = None + if len(story['owner_ids']) > 0: + assigned_to = users_bindings.get(story['owner_ids'][0], None) + + owner = users_bindings.get(story['requested_by_id'], self._user) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["pivotal", story['url']] + + us = UserStory.objects.create( + project=project, + owner=owner, + assigned_to=assigned_to, + status=project.us_statuses.get(slug=story['current_state']), + kanban_order=counter, + sprint_order=counter, + backlog_order=counter, + subject=story['name'], + description=story.get('description', ''), + tags=tags, + external_reference=external_reference, + milestone=story_milestone_binding.get(story['id'], None) + ) + + points = Points.objects.get(project=project, value=story.get('estimate', None)) + RolePoints.objects.filter(user_story=us, role__slug="main").update(points_id=points.id) + + if len(story['owner_ids']) > 1: + watchers = list(set(story['owner_ids'][1:] + story['follower_ids'])) + else: + watchers = story['follower_ids'] + + for watcher in watchers: + watcher_user = users_bindings.get(watcher, None) + if watcher_user: + us.add_watcher(watcher_user) + + if story.get('deadline', None): + us.custom_attributes_values.attributes_values = {due_date_field.id: story['deadline']} + us.custom_attributes_values.save() + if story.get('story_type', None): + us.custom_attributes_values.attributes_values = {story_type_field.id: story['story_type']} + us.custom_attributes_values.save() + + UserStory.objects.filter(id=us.id).update( + ref=story['id'], + modified_date=story['updated_at'], + created_date=story['created_at'] + ) + take_snapshot(us, comment="", user=None, delete=False) + + for label in story['labels']: + if epics.get(label['id'], None): + RelatedUserStory.objects.create( + epic=Epic.objects.get(project=project, ref=epics.get(label['id'])['id']), + user_story=us, + order=us.backlog_order + ) + self._import_tasks(project_data, us, story) + self._import_user_story_activity(project_data, us, story, options) + self._import_comments(project_data, us, story, options) + counter += 1 + + if len(stories['data']) < 300: + break + + def _import_epics_data(self, project_data, project, options): + users_bindings = options.get('users_bindings', {}) + counter = 0 + + for epic in project_data['epics']: + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["pivotal", epic['url']] + + taiga_epic = Epic.objects.create( + project=project, + owner=self._user, + status=project.epic_statuses.get(slug="new"), + epics_order=counter, + subject=epic['name'], + description=epic.get('description', ''), + tags=[], + external_reference=external_reference + ) + + Epic.objects.filter(id=taiga_epic.id).update( + ref=epic['id'], + modified_date=epic['updated_at'], + created_date=epic['created_at'] + ) + + for watcher in epic['follower_ids']: + watcher_user = users_bindings.get(watcher, None) + if watcher_user: + taiga_epic.add_watcher(watcher_user) + + take_snapshot(taiga_epic, comment="", user=None, delete=False) + self._import_comments(project_data, taiga_epic, epic, options) + self._import_epic_activity(project_data, taiga_epic, epic, options) + counter += 1 + + def _import_tasks(self, project_data, us, story): + for task in story['tasks']: + taiga_task = Task.objects.create( + subject=task['description'], + status=us.project.task_statuses.get(slug="complete" if task['complete'] else "incomplete"), + project=us.project, + us_order=task['position'], + taskboard_order=task['position'], + user_story=us + ) + + Task.objects.filter(id=taiga_task.id).update( + ref=task['id'], + modified_date=task['updated_at'], + created_date=task['created_at'] + ) + take_snapshot(taiga_task, comment="", user=None, delete=False) + + def _import_attachment(self, obj, attachment_id, attachment_name, created_at, person_id, options): + users_bindings = options.get('users_bindings', {}) + + data = self._client.get_attachment(attachment_id) + att = Attachment( + owner=users_bindings.get(person_id, self._user), + project=obj.project, + content_type=ContentType.objects.get_for_model(obj), + object_id=obj.id, + name=attachment_name, + size=len(data), + created_date=created_at, + is_deprecated=False, + ) + att.attached_file.save(attachment_name, ContentFile(data), save=True) + + def _import_comments(self, project_data, obj, story, options): + users_bindings = options.get('users_bindings', {}) + + for comment in story['comments']: + if 'text' in comment: + snapshot = take_snapshot( + obj, + comment=comment['text'], + user=users_bindings.get(comment['person']['id'], User(full_name=comment['person']['name'])), + delete=False + ) + HistoryEntry.objects.filter(id=snapshot.id).update(created_at=comment['created_at']) + for attachment in comment['file_attachments']: + self._import_attachment( + obj, + attachment['id'], + attachment['filename'], + comment['created_at'], + comment['person']['id'], + options + ) + + def _import_user_story_activity(self, project_data, us, story, options): + offset = 0 + while True: + activities = self._client.get( + "/projects/{}/stories/{}/activity".format( + project_data['id'], + story['id'], + ), + {"envelope": "true", "limit": 300, "offset": offset} + ) + offset += 300 + for activity in activities['data']: + self._import_activity(us, activity, options) + + if len(activities['data']) < 300: + break + + def _import_epic_activity(self, project_data, taiga_epic, epic, options): + offset = 0 + while True: + activities = self._client.get( + "/projects/{}/epics/{}/activity".format( + project_data['id'], + epic['id'], + ), + {"envelope": "true", "limit": 300, "offset": offset} + ) + offset += 300 + for activity in activities['data']: + self._import_activity(taiga_epic, activity, options) + + if len(activities['data']) < 300: + break + + def _import_activity(self, obj, activity, options): + activity_data = self._transform_activity_data(obj, activity, options) + if activity_data is None: + return + + change_old = activity_data['change_old'] + change_new = activity_data['change_new'] + hist_type = activity_data['hist_type'] + comment = activity_data['comment'] + user = activity_data['user'] + + key = make_key_from_model_object(activity_data['obj']) + typename = get_typename_for_model_class(type(activity_data['obj'])) + + diff = make_diff_from_dicts(change_old, change_new) + fdiff = FrozenDiff(key, diff, {}) + + entry = HistoryEntry.objects.create( + user=user, + project_id=obj.project.id, + key=key, + type=hist_type, + snapshot=None, + diff=fdiff.diff, + values=make_diff_values(typename, fdiff), + comment=comment, + comment_html=mdrender(obj.project, comment), + is_hidden=False, + is_snapshot=False, + ) + HistoryEntry.objects.filter(id=entry.id).update(created_at=activity['occurred_at']) + return HistoryEntry.objects.get(id=entry.id) + + def _transform_activity_data(self, obj, activity, options): + users_bindings = options.get('users_bindings', {}) + due_date_field = obj.project.userstorycustomattributes.get(name="Due date") + story_type_field = obj.project.userstorycustomattributes.get(name="Type") + + user = {"pk": None, "name": activity.get('performed_by', {}).get('name', None)} + taiga_user = users_bindings.get(activity.get('performed_by', {}).get('id', None), None) + if taiga_user: + user = {"pk": taiga_user.id, "name": taiga_user.get_full_name()} + + result = { + "change_old": {}, + "change_new": {}, + "hist_type": HistoryType.change, + "comment": "", + "user": user, + "obj": obj + } + + if activity['kind'] == "story_create_activity": + UserStory.objects.filter(id=obj.id, created_date__gt=activity['occurred_at']).update( + created_date=activity['occurred_at'], + owner=users_bindings.get(activity["performed_by"]["id"], self._user) + ) + return None + elif activity['kind'] == "epic_create_activity": + Epic.objects.filter(id=obj.id, created_date__gt=activity['occurred_at']).update( + created_date=activity['occurred_at'], + owner=users_bindings.get(activity["performed_by"]["id"], self._user) + ) + return None + elif activity['kind'] in ["story_update_activity", "epic_update_activity"]: + for change in activity['changes']: + if change['change_type'] != "update" or change['kind'] not in ["story", "epic"]: + continue + + if 'description' in change['new_values']: + result['change_old']["description"] = str(change['original_values']['description']) + result['change_new']["description"] = str(change['new_values']['description']) + result['change_old']["description_html"] = mdrender(obj.project, str(change['original_values']['description'])) + result['change_new']["description_html"] = mdrender(obj.project, str(change['new_values']['description'])) + + if 'estimate' in change['new_values']: + old_points = None + if change['original_values']['estimate']: + estimation = change['original_values']['estimate'] + (old_points, _) = Points.objects.get_or_create( + project=obj.project, + value=estimation, + defaults={ + "name": str(estimation), + "order": estimation, + } + ) + old_points = old_points.id + new_points = None + if change['new_values']['estimate']: + estimation = change['new_values']['estimate'] + (new_points, _) = Points.objects.get_or_create( + project=obj.project, + value=estimation, + defaults={ + "name": str(estimation), + "order": estimation, + } + ) + new_points = new_points.id + result['change_old']["points"] = {obj.project.roles.get(slug="main").id: old_points} + result['change_new']["points"] = {obj.project.roles.get(slug="main").id: new_points} + + if 'name' in change['new_values']: + result['change_old']["subject"] = change['original_values']['name'] + result['change_new']["subject"] = change['new_values']['name'] + + if 'labels' in change['new_values']: + result['change_old']["tags"] = [l.lower() for l in change['original_values']['labels']] + result['change_new']["tags"] = [l.lower() for l in change['new_values']['labels']] + + if 'current_state' in change['new_values']: + result['change_old']["status"] = obj.project.us_statuses.get(slug=change['original_values']['current_state']).id + result['change_new']["status"] = obj.project.us_statuses.get(slug=change['new_values']['current_state']).id + + if 'story_type' in change['new_values']: + if "custom_attributes" not in result['change_old']: + result['change_old']["custom_attributes"] = [] + if "custom_attributes" not in result['change_new']: + result['change_new']["custom_attributes"] = [] + + result['change_old']["custom_attributes"].append({ + "name": "Type", + "value": change['original_values']['story_type'], + "id": story_type_field.id + }) + result['change_new']["custom_attributes"].append({ + "name": "Type", + "value": change['new_values']['story_type'], + "id": story_type_field.id + }) + + if 'deadline' in change['new_values']: + if "custom_attributes" not in result['change_old']: + result['change_old']["custom_attributes"] = [] + if "custom_attributes" not in result['change_new']: + result['change_new']["custom_attributes"] = [] + + result['change_old']["custom_attributes"].append({ + "name": "Due date", + "value": change['original_values']['deadline'], + "id": due_date_field.id + }) + result['change_new']["custom_attributes"].append({ + "name": "Due date", + "value": change['new_values']['deadline'], + "id": due_date_field.id + }) + + # TODO: Process owners_ids + + elif activity['kind'] == "task_create_activity": + return None + elif activity['kind'] == "task_update_activity": + for change in activity['changes']: + if change['change_type'] != "update" or change['kind'] != "task": + continue + + try: + task = Task.objects.get(project=obj.project, ref=change['id']) + if 'description' in change['new_values']: + result['change_old']["subject"] = change['original_values']['description'] + result['change_new']["subject"] = change['new_values']['description'] + result['obj'] = task + if 'complete' in change['new_values']: + result['change_old']["status"] = obj.project.task_statuses.get(slug="complete" if change['original_values']['complete'] else "incomplete").id + result['change_new']["status"] = obj.project.task_statuses.get(slug="complete" if change['new_values']['complete'] else "incomplete").id + result['obj'] = task + except Task.DoesNotExist: + return None + + elif activity['kind'] == "comment_create_activity": + return None + elif activity['kind'] == "comment_update_activity": + return None + elif activity['kind'] == "story_move_activity": + return None + return result diff --git a/taiga/importers/pivotal/tasks.py b/taiga/importers/pivotal/tasks.py new file mode 100644 index 000000000..e00ee2e0f --- /dev/null +++ b/taiga/importers/pivotal/tasks.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import logging +import sys + +from django.utils.translation import gettext as _ + +from taiga.base.mails import mail_builder +from taiga.users.models import User +from taiga.celery import app +from .importer import PivotalImporter + +logger = logging.getLogger('taiga.importers.pivotal') + + +@app.task(bind=True) +def import_project(self, user_id, token, project_id, options): + user = User.object.get(id=user_id) + importer = PivotalImporter(user, token) + try: + project = importer.import_project(project_id, options) + except Exception as e: + # Error + ctx = { + "user": user, + "error_subject": _("Error importing PivotalTracker project"), + "error_message": _("Error importing PivotalTracker project"), + "project": project_id, + "exception": e + } + email = mail_builder.importer_import_error(user, ctx) + email.send() + logger.error('Error importing PivotalTracker project %s (by %s)', project_id, user, exc_info=sys.exc_info()) + else: + ctx = { + "project": project, + "user": user, + } + email = mail_builder.pivotal_import_success(user, ctx) + email.send() diff --git a/taiga/importers/services.py b/taiga/importers/services.py new file mode 100644 index 000000000..5d8bd6c9b --- /dev/null +++ b/taiga/importers/services.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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 = {} + for key,value in users_bindings.items(): + try: + user_key = int(key) + except ValueError: + user_key = key + + if isinstance(value, str): + try: + new_users_bindings[user_key] = User.objects.get(email__iexact=value) + except User.MultipleObjectsReturned: + new_users_bindings[user_key] = User.objects.get(email=value) + except User.DoesNotExist: + new_users_bindings[user_key] = None + else: + new_users_bindings[user_key] = User.objects.get(id=value) + return new_users_bindings + + +def create_memberships(users_bindings, project, creator, role_name): + for user in users_bindings.values(): + if Membership.objects.filter(project=project, user=user).count() > 0: + continue + Membership.objects.create( + user=user, + project=project, + role=project.get_roles().get(slug=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() diff --git a/taiga/importers/templates/emails/asana_import_success-body-html.jinja b/taiga/importers/templates/emails/asana_import_success-body-html.jinja new file mode 100644 index 000000000..0b7b3b8ab --- /dev/null +++ b/taiga/importers/templates/emails/asana_import_success-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +

Asana Project imported

+

Hello {{ user }},

+

Your Asana project has been correctly imported.

+ Go to {{ project }} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/importers/templates/emails/asana_import_success-body-text.jinja b/taiga/importers/templates/emails/asana_import_success-body-text.jinja new file mode 100644 index 000000000..de64d10d0 --- /dev/null +++ b/taiga/importers/templates/emails/asana_import_success-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +Hello {{ user }}, + +Your Asana project has been correctly imported. + +You can see the project {{ project }} here: + +{{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/importers/templates/emails/asana_import_success-subject.jinja b/taiga/importers/templates/emails/asana_import_success-subject.jinja new file mode 100644 index 000000000..d92b311d1 --- /dev/null +++ b/taiga/importers/templates/emails/asana_import_success-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %}[{{ project }}] Your Asana project has been imported{% endtrans %} diff --git a/taiga/importers/templates/emails/github_import_success-body-html.jinja b/taiga/importers/templates/emails/github_import_success-body-html.jinja new file mode 100644 index 000000000..f3fd01b49 --- /dev/null +++ b/taiga/importers/templates/emails/github_import_success-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +

GitHub Project imported

+

Hello {{ user }},

+

Your GitHub project has been correctly imported.

+ Go to {{ project }} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/importers/templates/emails/github_import_success-body-text.jinja b/taiga/importers/templates/emails/github_import_success-body-text.jinja new file mode 100644 index 000000000..9098299bf --- /dev/null +++ b/taiga/importers/templates/emails/github_import_success-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +Hello {{ user }}, + +Your GitHub project has been correctly imported. + +You can see the project {{ project }} here: + +{{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/importers/templates/emails/github_import_success-subject.jinja b/taiga/importers/templates/emails/github_import_success-subject.jinja new file mode 100644 index 000000000..8d030a27a --- /dev/null +++ b/taiga/importers/templates/emails/github_import_success-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %}[{{ project }}] Your GitHub project has been imported{% endtrans %} diff --git a/taiga/importers/templates/emails/importer_import_error-body-html.jinja b/taiga/importers/templates/emails/importer_import_error-body-html.jinja new file mode 100644 index 000000000..490d42f83 --- /dev/null +++ b/taiga/importers/templates/emails/importer_import_error-body-html.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), error_message=error_message, support_email=sr("support.email"), product_name=sr("product_name") %} +

{{ error_message }}

+

Hello {{ user }},

+

Your project has not been imported correctly.

+

The {{ product_name }} system administrators have been informed.
Please, try it again or contact with the support team at + {{ support_email }}

+

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/importers/templates/emails/importer_import_error-body-text.jinja b/taiga/importers/templates/emails/importer_import_error-body-text.jinja new file mode 100644 index 000000000..7baa1c294 --- /dev/null +++ b/taiga/importers/templates/emails/importer_import_error-body-text.jinja @@ -0,0 +1,22 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), error_message=error_message, support_email=sr("support.email"), product_name=sr("product_name") %} +Hello {{ user }}, + +{{ error_message }} + +Your project has not been imported correctly. + +The {{ product_name }} system administrators have been informed. + +Please, try it again or contact with the support team at {{ support_email }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/importers/templates/emails/importer_import_error-subject.jinja b/taiga/importers/templates/emails/importer_import_error-subject.jinja new file mode 100644 index 000000000..0194ee217 --- /dev/null +++ b/taiga/importers/templates/emails/importer_import_error-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans error_subject=error_subject|safe %}[Taiga] {{ error_subject }}{% endtrans %} diff --git a/taiga/importers/templates/emails/jira_import_success-body-html.jinja b/taiga/importers/templates/emails/jira_import_success-body-html.jinja new file mode 100644 index 000000000..2c54df97c --- /dev/null +++ b/taiga/importers/templates/emails/jira_import_success-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +

Jira Project imported

+

Hello {{ user }},

+

Your Jira project has been correctly imported.

+ Go to {{ project }} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/importers/templates/emails/jira_import_success-body-text.jinja b/taiga/importers/templates/emails/jira_import_success-body-text.jinja new file mode 100644 index 000000000..78c350906 --- /dev/null +++ b/taiga/importers/templates/emails/jira_import_success-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +Hello {{ user }}, + +Your Jira project has been correctly imported. + +You can see the project {{ project }} here: + +{{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/importers/templates/emails/jira_import_success-subject.jinja b/taiga/importers/templates/emails/jira_import_success-subject.jinja new file mode 100644 index 000000000..1a7186015 --- /dev/null +++ b/taiga/importers/templates/emails/jira_import_success-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %}[{{ project }}] Your Jira project has been imported{% endtrans %} diff --git a/taiga/importers/templates/emails/trello_import_success-body-html.jinja b/taiga/importers/templates/emails/trello_import_success-body-html.jinja new file mode 100644 index 000000000..be5c3f263 --- /dev/null +++ b/taiga/importers/templates/emails/trello_import_success-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +

Trello Project imported

+

Hello {{ user }},

+

Your Trello project has been correctly imported.

+ Go to {{ project }} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/importers/templates/emails/trello_import_success-body-text.jinja b/taiga/importers/templates/emails/trello_import_success-body-text.jinja new file mode 100644 index 000000000..97146d03b --- /dev/null +++ b/taiga/importers/templates/emails/trello_import_success-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %} +Hello {{ user }}, + +Your Trello project has been correctly imported. + +You can see the project {{ project }} here: + +{{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/importers/templates/emails/trello_import_success-subject.jinja b/taiga/importers/templates/emails/trello_import_success-subject.jinja new file mode 100644 index 000000000..b371cb17c --- /dev/null +++ b/taiga/importers/templates/emails/trello_import_success-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %}[{{ project }}] Your Trello project has been imported{% endtrans %} diff --git a/taiga/importers/trello/api.py b/taiga/importers/trello/api.py new file mode 100644 index 000000000..e8b3a130e --- /dev/null +++ b/taiga/importers/trello/api.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.utils.translation import gettext as _ +from django.conf import settings + +from taiga.base.api import viewsets +from taiga.base import response +from taiga.base import exceptions as exc +from taiga.base.decorators import list_route +from taiga.users.models import AuthData, User +from taiga.users.services import get_user_photo_url +from taiga.users.gravatar import get_user_gravatar_id + +from .importer import TrelloImporter +from taiga.importers import permissions +from taiga.importers.services import resolve_users_bindings +from . import tasks + + +class TrelloImporterViewSet(viewsets.ViewSet): + permission_classes = (permissions.ImporterPermission,) + + @list_route(methods=["POST"]) + def list_users(self, request, *args, **kwargs): + self.check_permissions(request, "list_users", None) + + token = request.DATA.get('token', None) + project_id = request.DATA.get('project', None) + + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + importer = TrelloImporter(request.user, token) + users = importer.list_users(project_id) + for user in users: + user['user'] = None + if not user['email']: + continue + + try: + taiga_user = User.objects.get(email=user['email']) + except User.DoesNotExist: + continue + + user['user'] = { + 'id': taiga_user.id, + 'full_name': taiga_user.get_full_name(), + 'gravatar_id': get_user_gravatar_id(taiga_user), + 'photo': get_user_photo_url(taiga_user), + } + return response.Ok(users) + + @list_route(methods=["POST"]) + def list_projects(self, request, *args, **kwargs): + self.check_permissions(request, "list_projects", None) + token = request.DATA.get('token', None) + importer = TrelloImporter(request.user, token) + projects = importer.list_projects() + return response.Ok(projects) + + @list_route(methods=["POST"]) + def import_project(self, request, *args, **kwargs): + self.check_permissions(request, "import_project", None) + + token = request.DATA.get('token', None) + project_id = request.DATA.get('project', None) + if not project_id: + raise exc.WrongArguments(_("The project param is needed")) + + options = { + "name": request.DATA.get('name', None), + "description": request.DATA.get('description', None), + "template": request.DATA.get('template', "kanban"), + "users_bindings": resolve_users_bindings(request.DATA.get("users_bindings", {})), + "keep_external_reference": request.DATA.get("keep_external_reference", False), + "is_private": request.DATA.get("is_private", False), + } + + if settings.CELERY_ENABLED: + task = tasks.import_project.delay(request.user.id, token, project_id, options) + return response.Accepted({"task_id": task.id}) + + importer = TrelloImporter(request.user, token) + project = importer.import_project(project_id, options) + project_data = { + "slug": project.slug, + "my_permissions": ["view_us"], + "is_backlog_activated": project.is_backlog_activated, + "is_kanban_activated": project.is_kanban_activated, + } + + return response.Ok(project_data) + + @list_route(methods=["GET"]) + def auth_url(self, request, *args, **kwargs): + self.check_permissions(request, "auth_url", None) + + (oauth_token, oauth_secret, url) = TrelloImporter.get_auth_url() + + (auth_data, created) = AuthData.objects.get_or_create( + user=request.user, + key="trello-oauth", + defaults={ + "value": uuid.uuid4().hex, + "extra": {}, + } + ) + auth_data.extra = { + "oauth_token": oauth_token, + "oauth_secret": oauth_secret, + } + auth_data.save() + + return response.Ok({"url": url}) + + @list_route(methods=["POST"]) + def authorize(self, request, *args, **kwargs): + self.check_permissions(request, "authorize", None) + + try: + oauth_data = request.user.auth_data.get(key="trello-oauth") + oauth_token = oauth_data.extra['oauth_token'] + oauth_secret = oauth_data.extra['oauth_secret'] + oauth_verifier = request.DATA.get('code') + oauth_data.delete() + trello_token = TrelloImporter.get_access_token(oauth_token, oauth_secret, oauth_verifier)['oauth_token'] + except Exception as e: + raise exc.WrongArguments(_("Invalid or expired auth token")) + + return response.Ok({ + "token": trello_token + }) diff --git a/taiga/importers/trello/importer.py b/taiga/importers/trello/importer.py new file mode 100644 index 000000000..43a2e41a5 --- /dev/null +++ b/taiga/importers/trello/importer.py @@ -0,0 +1,555 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from requests_oauthlib import OAuth1Session, OAuth1 +from django.conf import settings +from django.core.files.base import ContentFile +from django.contrib.contenttypes.models import ContentType +import requests +import webcolors + +from django.template.defaultfilters import slugify +from taiga.base import exceptions as exc +from taiga.projects.services import projects as projects_service +from taiga.projects.models import Project, ProjectTemplate +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task +from taiga.projects.attachments.models import Attachment +from taiga.projects.history.services import (make_diff_from_dicts, + make_diff_values, + make_key_from_model_object, + get_typename_for_model_class, + FrozenDiff) +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.choices import HistoryType +from taiga.projects.custom_attributes.models import UserStoryCustomAttribute +from taiga.mdrender.service import render as mdrender +from taiga.timeline.rebuilder import rebuild_timeline +from taiga.timeline.models import Timeline +from taiga.front.templatetags.functions import resolve as resolve_front_url +from taiga.importers import services as import_service + +from taiga.base import exceptions + + +class TrelloClient: + def __init__(self, api_key, api_secret, token): + self.api_key = api_key + self.api_secret = api_secret + self.token = token + if self.token: + self.oauth = OAuth1( + client_key=self.api_key, + client_secret=self.api_secret, + resource_owner_key=self.token + ) + else: + self.oauth = None + + def _validate_response(self, response): + if response.status_code == 400: + raise exc.WrongArguments(_("Invalid Request: %(text)s at %(url)s") % ({"text": response.text, "url": response.url})) + if response.status_code == 401: + raise exc.AuthenticationFailed(_("Unauthorized: %(text)s at %(url)s") % ({"text": response.text, "url": response.url})) + if response.status_code == 403: + raise exc.PermissionDenied(_("Unauthorized: %(text)s at %(url)s") % ({"text": response.text, "url": response.url})) + if response.status_code == 404: + raise exc.NotFound(_("Resource Unavailable: %(text)s at %(url)s") % ({"text": response.text, "url": response.url})) + if response.status_code != 200: + raise exc.WrongArguments(_("Resource Unavailable: %(text)s at %(url)s") % ({"text": response.text, "url": response.url})) + + def get(self, uri_path, query_params=None): + headers = {'Accept': 'application/json'} + + if query_params is None: + query_params = {} + + if uri_path[0] == '/': + uri_path = uri_path[1:] + url = 'https://api.trello.com/1/%s' % uri_path + + response = requests.get(url, params=query_params, headers=headers, auth=self.oauth) + self._validate_response(response) + return response.json() + + def download(self, url): + response = requests.get(url, auth=self.oauth) + self._validate_response(response) + return response.content + + +class TrelloImporter: + def __init__(self, user, token): + self._user = user + self._cached_orgs = {} + self._client = TrelloClient( + api_key=settings.IMPORTERS.get('trello', {}).get('api_key', None), + api_secret=settings.IMPORTERS.get('trello', {}).get('secret_key', None), + token=token, + ) + + def list_projects(self): + projects_data = self._client.get("/members/me/boards", { + "fields": "id,name,desc,prefs,idOrganization", + "organization": "true", + "organization_fields": "prefs", + }) + projects = [] + for project in projects_data: + is_private = False + if project['prefs']['permissionLevel'] == "private": + is_private = True + + if project['prefs']['permissionLevel'] == "org": + if 'organization' not in project: + is_private = True + elif 'prefs' not in project['organization']: + is_private = True + elif project['organization']['prefs']['permissionLevel'] == "private": + is_private = True + + projects.append({ + "id": project['id'], + "name": project['name'], + "description": project['desc'], + "is_private": is_private, + }) + return projects + + def list_users(self, project_id): + members = [] + for member in self._client.get("/board/{}/members/all".format(project_id), {"fields": "id"}): + user = self._client.get("/member/{}".format(member['id']), {"fields": "id,fullName,email,avatarSource,avatarHash,gravatarHash"}) + avatar = None + try: + if user['avatarSource'] == "gravatar" and user['gravatarHash']: + avatar = 'https://www.gravatar.com/avatar/' + user['gravatarHash'] + '.jpg?s=50' + elif user['avatarHash'] is not None: + avatar = 'https://trello-members.s3.amazonaws.com/' + user['id'] + '/' + user['avatarHash'] + '/50.png' + except: + # NOTE: Sometimes this piece of code return this exception: + # + # File "/home/taiga/taiga-back/taiga/importers/trello/importer.py" in list_users + # 135. avatar = 'https://trello-members.s3.amazonaws.com/' + user['id'] + '/' + user['avatarHash'] + '/50.png' + # + # Exception Type: TypeError at /api/v1/importers/trello/list_users + # Exception Value: Can't convert 'NoneType' object to str implicitly + pass + + members.append({ + "id": user['id'], + "full_name": user['fullName'], + "email": user['email'], + "avatar": avatar + }) + return members + + def import_project(self, project_id, options): + data = self._client.get( + "/board/{}".format(project_id), + { + "fields": "name,desc", + "cards": "all", + "card_fields": "closed,labels,idList,desc,due,name,pos,dateLastActivity,idChecklists,idMembers,url", + "card_attachments": "true", + "labels": "all", + "labels_limit": "1000", + "lists": "all", + "list_fields": "closed,name,pos", + "members": "none", + "checklists": "all", + "checklist_fields": "name", + "organization": "true", + "organization_fields": "logoHash", + } + ) + + project = self._import_project_data(data, options) + self._import_user_stories_data(data, project, options) + self._cleanup(project, options) + Timeline.objects.filter(project=project).delete() + rebuild_timeline(None, None, project.id) + return project + + def _import_project_data(self, data, options): + board = data + labels = board['labels'] + statuses = board['lists'] + project_template = ProjectTemplate.objects.get(slug=options.get('template', "kanban")) + project_template.us_statuses = [] + counter = 0 + for us_status in statuses: + if counter == 0: + project_template.default_options["us_status"] = us_status['name'] + + counter += 1 + if us_status['name'] not in [s['name'] for s in project_template.us_statuses]: + project_template.us_statuses.append({ + "name": us_status['name'], + "slug": slugify(us_status['name']), + "is_closed": False, + "is_archived": True if us_status['closed'] else False, + "color": "#999999", + "wip_limit": None, + "order": us_status['pos'], + }) + + project_template.task_statuses = [] + project_template.task_statuses.append({ + "name": "Incomplete", + "slug": "incomplete", + "is_closed": False, + "color": "#ff8a84", + "order": 1, + }) + project_template.task_statuses.append({ + "name": "Complete", + "slug": "complete", + "is_closed": True, + "color": "#669900", + "order": 2, + }) + project_template.default_options["task_status"] = "Incomplete" + project_template.roles.append({ + "name": "Trello", + "slug": "trello", + "computable": False, + "permissions": project_template.roles[0]['permissions'], + "order": 70, + }) + + tags_colors = [] + for label in labels: + name = label['name'] + if not name: + name = label['color'] + name = name.lower() + color = self._ensure_hex_color(label['color']) + tags_colors.append([name, color]) + + project = Project( + name=options.get('name', None) or board['name'], + description=options.get('description', None) or board['desc'], + owner=self._user, + tags_colors=tags_colors, + creation_template=project_template, + is_private=options.get('is_private', False), + ) + (can_create, error_message, total_members) = projects_service.check_if_project_can_be_created_or_updated(project) + if not can_create: + raise exceptions.NotEnoughSlotsForProject(project.is_private, total_members or 1, error_message) + project.save() + + if board.get('organization', None): + trello_avatar_template = "https://trello-logos.s3.amazonaws.com/{}/170.png" + project_logo_url = trello_avatar_template.format(board['organization']['logoHash']) + data = requests.get(project_logo_url) + project.logo.save("logo.png", ContentFile(data.content), save=True) + + UserStoryCustomAttribute.objects.create( + name="Due", + description="Due date", + type="date", + order=1, + 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): + users_bindings = options.get('users_bindings', {}) + statuses = {s['id']: s for s in data['lists']} + cards = data['cards'] + due_date_field = project.userstorycustomattributes.first() + + for card in cards: + if card['closed'] and not options.get("import_closed_data", False): + continue + if statuses[card['idList']]['closed'] and not options.get("import_closed_data", False): + continue + + tags = [] + for tag in card['labels']: + name = tag['name'] + if not name: + name = tag['color'] + name = name.lower() + tags.append(name) + + assigned_to = None + if len(card['idMembers']) > 0: + assigned_to = users_bindings.get(card['idMembers'][0], None) + + external_reference = None + if options.get('keep_external_reference', False): + external_reference = ["trello", card['url']] + + us = UserStory.objects.create( + project=project, + owner=self._user, + assigned_to=assigned_to, + status=project.us_statuses.get(name=statuses[card['idList']]['name']), + kanban_order=card['pos'], + sprint_order=card['pos'], + backlog_order=card['pos'], + subject=card['name'], + description=card['desc'], + tags=tags, + external_reference=external_reference + ) + + if len(card['idMembers']) > 1: + for watcher in card['idMembers'][1:]: + watcher_user = users_bindings.get(watcher, None) + if watcher_user: + us.add_watcher(watcher_user) + + if card['due']: + us.custom_attributes_values.attributes_values = {due_date_field.id: card['due']} + us.custom_attributes_values.save() + + UserStory.objects.filter(id=us.id).update( + modified_date=card['dateLastActivity'], + created_date=card['dateLastActivity'] + ) + self._import_attachments(us, card, options) + self._import_tasks(data, us, card) + self._import_actions(us, card, statuses, options) + + def _import_tasks(self, data, us, card): + checklists_by_id = {c['id']: c for c in data['checklists']} + for checklist_id in card['idChecklists']: + for item in checklists_by_id.get(checklist_id, {}).get('checkItems', []): + Task.objects.create( + subject=item['name'], + status=us.project.task_statuses.get(slug=item['state']), + project=us.project, + user_story=us + ) + + def _import_attachments(self, us, card, options): + users_bindings = options.get('users_bindings', {}) + for attachment in card['attachments']: + if attachment['bytes'] is None: + continue + + data = self._client.download(attachment['url']) + + att = Attachment( + owner=users_bindings.get(attachment['idMember'], self._user) or self._user, + project=us.project, + content_type=ContentType.objects.get_for_model(UserStory), + object_id=us.id, + name=attachment['name'], + size=attachment['bytes'], + created_date=attachment['date'], + is_deprecated=False, + ) + + file_name = attachment['name'][:-1] if attachment['name'].endswith('/') else attachment['name'] + att.attached_file.save(file_name, ContentFile(data), save=True) + + UserStory.objects.filter(id=us.id, created_date__gt=attachment['date']).update( + created_date=attachment['date'] + ) + + def _import_actions(self, us, card, statuses, options): + included_actions = [ + "addAttachmentToCard", "addMemberToCard", "commentCard", + "convertToCardFromCheckItem", "copyCommentCard", "createCard", + "deleteAttachmentFromCard", "deleteCard", "removeMemberFromCard", + "updateCard", + ] + + actions = self._client.get( + "/card/{}/actions".format(card['id']), + { + "filter": ",".join(included_actions), + "limit": "1000", + "memberCreator": "true", + "memberCreator_fields": "fullName", + } + ) + + while actions: + for action in actions: + self._import_action(us, action, statuses, options) + actions = self._client.get( + "/card/{}/actions".format(card['id']), + { + "filter": ",".join(included_actions), + "limit": "1000", + "since": "lastView", + "before": action['date'], + "memberCreator": "true", + "memberCreator_fields": "fullName", + } + ) + + def _import_action(self, us, action, statuses, options): + key = make_key_from_model_object(us) + typename = get_typename_for_model_class(UserStory) + action_data = self._transform_action_data(us, action, statuses, options) + if action_data is None: + return + + change_old = action_data['change_old'] + change_new = action_data['change_new'] + hist_type = action_data['hist_type'] + comment = action_data['comment'] + user = action_data['user'] + + diff = make_diff_from_dicts(change_old, change_new) + fdiff = FrozenDiff(key, diff, {}) + + entry = HistoryEntry.objects.create( + user=user, + project_id=us.project.id, + key=key, + type=hist_type, + snapshot=None, + diff=fdiff.diff, + values=make_diff_values(typename, fdiff), + comment=comment, + comment_html=mdrender(us.project, comment), + is_hidden=False, + is_snapshot=False, + ) + HistoryEntry.objects.filter(id=entry.id).update(created_at=action['date']) + return HistoryEntry.objects.get(id=entry.id) + + def _transform_action_data(self, us, action, statuses, options): + users_bindings = options.get('users_bindings', {}) + due_date_field = us.project.userstorycustomattributes.first() + + ignored_actions = ["addAttachmentToCard", "addMemberToCard", + "deleteAttachmentFromCard", "deleteCard", + "removeMemberFromCard"] + + if action['type'] in ignored_actions: + return None + + user = {"pk": None, "name": action.get('memberCreator', {}).get('fullName', None)} + taiga_user = users_bindings.get(action.get('memberCreator', {}).get('id', None), None) + if taiga_user: + user = {"pk": taiga_user.id, "name": taiga_user.get_full_name()} + + result = { + "change_old": {}, + "change_new": {}, + "hist_type": HistoryType.change, + "comment": "", + "user": user + } + + if action['type'] == "commentCard": + result['comment'] = str(action['data']['text']) + elif action['type'] == "convertToCardFromCheckItem": + UserStory.objects.filter(id=us.id, created_date__gt=action['date']).update( + created_date=action['date'], + owner=users_bindings.get(action["idMemberCreator"], self._user) or self._user + ) + result['hist_type'] = HistoryType.create + elif action['type'] == "copyCommentCard": + UserStory.objects.filter(id=us.id, created_date__gt=action['date']).update( + created_date=action['date'], + owner=users_bindings.get(action["idMemberCreator"], self._user) or self._user + ) + result['hist_type'] = HistoryType.create + elif action['type'] == "createCard": + UserStory.objects.filter(id=us.id, created_date__gt=action['date']).update( + created_date=action['date'], + owner=users_bindings.get(action["idMemberCreator"], self._user) or self._user + ) + result['hist_type'] = HistoryType.create + elif action['type'] == "updateCard": + if 'desc' in action['data']['old']: + result['change_old']["description"] = str(action['data']['old'].get('desc', '')) + result['change_new']["description"] = str(action['data']['card'].get('desc', '')) + result['change_old']["description_html"] = mdrender(us.project, str(action['data']['old'].get('desc', ''))) + result['change_new']["description_html"] = mdrender(us.project, str(action['data']['card'].get('desc', ''))) + if ( + 'idList' in action['data']['old'] + and action["data"]["old"]["idList"] in statuses + and action["data"]["card"]["idList"] in statuses + ): + old_status_name = statuses[action['data']['old']['idList']]['name'] + result['change_old']["status"] = us.project.us_statuses.get(name=old_status_name).id + new_status_name = statuses[action['data']['card']['idList']]['name'] + result['change_new']["status"] = us.project.us_statuses.get(name=new_status_name).id + if 'name' in action['data']['old']: + result['change_old']["subject"] = action['data']['old']['name'] + result['change_new']["subject"] = action['data']['card']['name'] + if 'due' in action['data']['old']: + result['change_old']["custom_attributes"] = [{ + "name": "Due", + "value": action['data']['old']['due'], + "id": due_date_field.id + }] + result['change_new']["custom_attributes"] = [{ + "name": "Due", + "value": action['data']['card']['due'], + "id": due_date_field.id + }] + + if result['change_old'] == {}: + return None + return result + + @classmethod + def get_auth_url(cls): + request_token_url = 'https://trello.com/1/OAuthGetRequestToken' + authorize_url = 'https://trello.com/1/OAuthAuthorizeToken' + return_url = resolve_front_url("new-project-import", "trello") + expiration = "1day" + scope = "read,account" + trello_key = settings.IMPORTERS.get('trello', {}).get('api_key', None) + trello_secret = settings.IMPORTERS.get('trello', {}).get('secret_key', None) + name = "Taiga" + + session = OAuth1Session(client_key=trello_key, client_secret=trello_secret) + response = session.fetch_request_token(request_token_url) + oauth_token, oauth_token_secret = response.get('oauth_token'), response.get('oauth_token_secret') + + return ( + oauth_token, + oauth_token_secret, + "{authorize_url}?oauth_token={oauth_token}&scope={scope}&expiration={expiration}&name={name}&return_url={return_url}".format( + authorize_url=authorize_url, + oauth_token=oauth_token, + expiration=expiration, + scope=scope, + name=name, + return_url=return_url, + ) + ) + + @classmethod + def get_access_token(cls, oauth_token, oauth_token_secret, oauth_verifier): + api_key = settings.IMPORTERS.get('trello', {}).get('api_key', None) + api_secret = settings.IMPORTERS.get('trello', {}).get('secret_key', None) + access_token_url = 'https://trello.com/1/OAuthGetAccessToken' + session = OAuth1Session(client_key=api_key, client_secret=api_secret, + resource_owner_key=oauth_token, resource_owner_secret=oauth_token_secret, + verifier=oauth_verifier) + access_token = session.fetch_access_token(access_token_url) + return access_token + + def _ensure_hex_color(self, color): + if color is None: + return None + try: + return webcolors.name_to_hex(color) + except ValueError: + return color + + def _cleanup(self, project, options): + if not options.get("import_closed_data", False): + project.us_statuses.filter(is_archived=True).delete() diff --git a/taiga/importers/trello/tasks.py b/taiga/importers/trello/tasks.py new file mode 100644 index 000000000..832c83b13 --- /dev/null +++ b/taiga/importers/trello/tasks.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import logging +import sys + +from django.utils.translation import gettext as _ + +from taiga.base.mails import mail_builder +from taiga.users.models import User +from taiga.celery import app +from .importer import TrelloImporter + +logger = logging.getLogger('taiga.importers.trello') + + +@app.task(bind=True) +def import_project(self, user_id, token, project_id, options): + user = User.objects.get(id=user_id) + importer = TrelloImporter(user, token) + try: + project = importer.import_project(project_id, options) + except Exception as e: + # Error + ctx = { + "user": user, + "error_subject": _("Error importing Trello project"), + "error_message": _("Error importing Trello project"), + "project": project_id, + "exception": e + } + email = mail_builder.importer_import_error(user, ctx) + email.send() + logger.error('Error importing Trello project %s (by %s)', project_id, user, exc_info=sys.exc_info()) + else: + ctx = { + "project": project, + "user": user, + } + email = mail_builder.trello_import_success(user, ctx) + email.send() diff --git a/taiga/locale/__init__.py b/taiga/locale/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/locale/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/locale/api.py b/taiga/locale/api.py new file mode 100644 index 000000000..c0568dca2 --- /dev/null +++ b/taiga/locale/api.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings + +from taiga.base import response +from taiga.base.api.viewsets import ReadOnlyListViewSet + +from . import permissions + + +class LocalesViewSet(ReadOnlyListViewSet): + permission_classes = (permissions.LocalesPermission,) + + def list(self, request, *args, **kwargs): + locales = [{"code": c, "name": n, "bidi": c in settings.LANGUAGES_BIDI} for c, n in settings.LANGUAGES] + return response.Ok(locales) diff --git a/taiga/locale/ar/LC_MESSAGES/django.po b/taiga/locale/ar/LC_MESSAGES/django.po new file mode 100644 index 000000000..79da81aae --- /dev/null +++ b/taiga/locale/ar/LC_MESSAGES/django.po @@ -0,0 +1,4799 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Nizaar Abdelkader , 2018 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2022-01-07 20:55+0000\n" +"Last-Translator: Ali Al-Ghanim \n" +"Language-Team: Arabic \n" +"Language: ar\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " +"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n" +"X-Generator: Weblate 4.10.1\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "نوع تسجيل دخول غير صالح" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "التسجيل العام معطل." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "يجب عليك قبول شروطنا للخدمة و سياستنا للخصوصية" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "خطأ في نوع التسجيل" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "اسم مستخدم غير صالح" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "مطلوب. 255 حرفاً او اقل. حروف، ارقام و /./-/_ رموز" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "الاسم الكامل غير صالح" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "اسم المستخدم مستعمل حاليا." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "البريد الإلكتروني مستعمل حاليا." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "المستخدم مسجّل من قبل." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "حدث خطأ عند انشاء المستخدم." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "مستخدم" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +#, fuzzy +#| msgid "Create" +msgid "created at" +msgstr "إنشاء" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "هذا الحقل مطلوب." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "القيمة غير صالحة." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' القيمة يجب أن تكون صح أو خطأ." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"أدخل قيمة صالحة \"سبيكة\" تكون من الحروف ، الارقام ، الشرطات السفلية أو " +"الوصلات." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "حدد اختياراً صالحاً. %(value)s ليس من الخيارات المتوفرة." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "مجال البريد الالكتروني غير متاح" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "الرجاء ادخال بريد إلكتروني صالح." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "صيغة التاريخ خاطئة. استخدم احدى هذه الصيغ بالمقابل: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "صيغة التاريخ و الوقت خاطئة. استخدم احدى هذه الصيغ بالمقابل: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "صيغة الوقت خاطئة. استخدم احدى هذه الصيغ بالمقابل: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "ادخل الرقم بالكامل." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "تأكد أن تكون هذه القيمة أقل أو تساوي %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "تأكد أن تكون هذه القيمة أعلى أو تساوي %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "يجب أن تكون قيمة \"%s\" عددًا عشريًا." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "أدخل رقم." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "تأكد من عدم وجود أكثر من %s رقم في المجموع." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "تأكد من عدم وجود أكثر %s من المنازل العشرية." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "تأكد من عدم وجود أكثر من أرقام %s قبل الفاصلة العشرية." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "لم يتم تقديم أي ملف. تحقق من نوع الترميز في النموذج." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "لم يتم تقديم أي ملف." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "الملف المقدم فارغ." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"تأكد من أن اسم الملف هذا يحتوي على أكثر من %(max)d أحرف (يحتوي على " +"%(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "يرجى إما إرسال ملف أو تحديد مربع الاختيار ، وليس كلاهما." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"الرجاء رفع صورة صالحة. الملف الذي تم رفعه اما انه ليس بصورة او صورة معطلة." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "عنصر محظور" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "الصفحة ليس \"الاخيرة\" ، و لا يمكن تحويلها إلى عدد صحيح." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "صفحة معطلة (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "تعريف إذن غير صالح." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "التايغا" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "التحديثات" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "الاسم" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "موقع" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "وصف" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "تطبيق" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "رمز غير صالح" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "تعليق" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "خصوصية" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "وحدات" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "نشاط" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "مشجعين" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "مالك" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "هذا الحقل مطلوب." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "مشروع" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "طلب" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "نص" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "التاريخ" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "رابط" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "القائمة" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "خانة الاختيار" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "رقم" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "نوع" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "قيم" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "ملحمي" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "مهمة" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "مرجع" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "الحالة" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "الموضوع" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "لون" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "تغيير" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "إنشاء" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "حذف" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "من" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "إلى" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "مهمل" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "تمت الاضافة" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "تمت الازالة" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "غير معين" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-محذوف-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "إلى:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "من:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "تمت اضافته" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "تم التغيير" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "تم الحذف" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "تمت اضافته:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "تمت ازالته:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "من:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "إلى:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "محتوى" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "العدو" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "شده" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "أولوية" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "حدث رئيسي" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "إعجاب" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "الاعجابات" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "سبيكة" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "عدم المسؤولية" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "البريد الالكتروني" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "رمز" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "الشعار" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "اعضاء" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "عدد" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "قيمة" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "نقاط" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "أولويات" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "شدة" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "الأدوار" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "متضمن" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "جميع" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "لا احد" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "مكلف" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "مذكور" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "تعليق" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "تمت مشاهدته" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "إصدار" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "الرمز لا يتطابق مع أي دعوة صالحة." + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "هذا المستخدم عضو في المشروع من قبل." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "Enter a valid email address." +msgid "Malformed email adress." +msgstr "الرجاء ادخال بريد إلكتروني صالح." + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "الجدول الزمني" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "الملاحم" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "تراكم" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "كانبان" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "مشاكل" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "العلامات" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "شخص" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "إستكمال" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "؟" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "جديد" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "جاهز" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "تم" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "مؤرشف" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "مغلق" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "مؤجل" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "مرفوض" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "خلل" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "سؤال" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "تعزيز" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "منخفض" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "عادي" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "عالي" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "قائمة الرغبات" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "مصغر" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "مهم" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "حرج" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "تجربة المستخدم" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "تصميم" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "أمام" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "خلف" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "أصحاب المصلحه" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "دور" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "نقاط" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "أولويات" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "اعضاء" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +#, fuzzy +#| msgid "status" +msgid "staff status" +msgstr "الحالة" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/ca/LC_MESSAGES/django.po b/taiga/locale/ca/LC_MESSAGES/django.po new file mode 100644 index 000000000..429da6908 --- /dev/null +++ b/taiga/locale/ca/LC_MESSAGES/django.po @@ -0,0 +1,4868 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Xaviju , 2015 +# Kelp , 2021 +# Taiga Dev Team , 2015 +# Xaviju , 2015,2021 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2023-01-07 11:48+0000\n" +"Last-Translator: jamor \n" +"Language-Team: Catalan \n" +"Language: ca\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.15.1-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "Sistema de login invàlid" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "El registre públic està deshabilitat." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "Has d'acceptar els nostres termes de servei i política de privacitat" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "Sistema de registre invàlid" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "No s'ha trobat" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "Rol invàlid per al projecte" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "Token invàlid" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "nom d'usuari invàlid" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Requerit. 255 caràcters o menys. Lletres, nombres i caràcters /./-/_" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Nom complet invàlid" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "El mot d'usuari ja està en ús." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Aquest e-mail ja està en ús." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Aquest usuari ja està registrat." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Error creant un nou usuari." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "Token invàlid" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token has no id" +msgstr "Token invàlid" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Token invàlid" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Aquest camp es obligatori." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Valor invàlid." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' valor ha de ser Verdader o Fals." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "Introdueix un 'slug' vàlid: lletres, nombres, barra baixa o guió." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "Selecciona una opció vàlida. %(value)s no es una opció vàlida." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "El domini d'aquest correu no està permés" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Introduïu una adreça de correu electrònic vàlida." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "La data te un format erroni. Utilitza un del següents formats: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "La data te un format erroni. Utilitza un del següents formats: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "L'hora te un format erroni. Utilitza un del següents formats: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Introdueix un nombre complet." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Asegurat que aquest valor es inferior i igual a %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Asegurat que aquest valor es superior o igual a %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" deu ser un float." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Introdueix un nombre." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Asegurat que no hi ha més de %s digits en total." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Asegurat que no hi ha més de %s posicions decimals." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Asegurat que no hi ha més de %s dígits abans del decimal." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Cap fitxer enviat. Comprova el tipus de codificació en el formulari." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Cap fitxer enviat." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "El fitxer enviat està buit." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Assegureu-vos que aquest nom de fitxer tingui com a màxim %(max)d caràcters " +"(ara té %(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "Per favor envia un fitxer o cancela el checkbox, pero no ambdós." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Puja una imatge vàlida. El fitxer que has pujat no ès una imatge o el fitxer " +"està corrupte." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Element bloquejat" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "La pàgina no és la 'última' ni pot ser convertida a un enter." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Pàgina invàlida (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Definició de permís invàlida." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Invàlid pk '%s' - l'objecte no existeix." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Tipus incorrecte. S'espera valor pk, rebut '1%s'." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "L'objecte amb %s=%s' no existeix." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Enllaç no vàlid - No es troba l'URL" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Enllaç no vàlid - L'URL no és correcta" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Enllaç no vàlid a causa d'un error de configuració" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Enllaç invàlid: l'objecte no existeix." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Tipus incorrecte. S'esperava una cadena URL i s'ha rebut %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Dades no vàlides" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "No s'han introduït dades" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"No es pot crear un element nou, només es poden actualitzar els elements " +"existents." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "S'esperava una llista d'elements." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "No s'ha trobat" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Permís denegat" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Error a l'aplicació del servidor" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Error de connexió." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Consulta amb format incorrecte." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Credencials d'autenticació incorrectes." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "No s'han proporcionat les credencials d'autenticació." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "No teniu permisos per realitzar aquesta acció." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Mètode '%s' no permès." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "No s'ha pogut satisfer la petició de la capçalera Accept" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Petició amb tipus de suport '%s' no compatible." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Excés de peticions." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Estarà disponible en %d segon%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Error inesperat" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "No s'ha trobat." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Mètode no suportat per aquest endpoint." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Arguments invàlids." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Validació de data errònia" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Error d'integritat per arguments incorrectes o no vàlids" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Precondició errònia" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "No queda espai per a més projectes." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s no és una llista" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Has sigut Taigatizat" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Segueix-nos a Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Aconsegueix el codi a Github" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Actualitzacions" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Actualitzacions" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Comentari: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Es necessita l'arxiu de bolcat de dades" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Format d'arxiu dump invàlid" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] El bolcat de dades del teu projecte ha sigut generat" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] El teu bolcat de dades ha sigut importat" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Contingut invàlid. Deu ser {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "Nom" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "Descripció" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Token invàlid" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "Nom complet" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "Adreça d'email" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "Comentari" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "Data de creació" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Feedback

\n" +"

Taiga ha rebut feedback de %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Comentari

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Informació extra" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- De: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comentari:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Informació extra:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Feedback de %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "El projecte no existeix" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Firma no vàlida" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Informació de comentari d'incidència no vàlida" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Informació d'incidència no vàlida" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "L'element referenciat no existeix" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "L'estatus no existeix" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Veure projecte" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Veure fita" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Veure història d'usuari" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Veure tasca" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Veure incidència" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Veure pàgina del wiki" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Veure links del wiki" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Afegeix fita" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Modifica fita" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Borra fita" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Esborrar èpica" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Veure història d'usuari" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Afegeix història d'usuari" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Modifica història d'usuari" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Borra història d'usuari" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Afegeix tasca" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Modifica tasca" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Borra tasca" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Afegeix incidència" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Modifica incidència" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Borra incidència" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Afegeix pàgina del wiki" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Modifica pàgina del wiki" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Borra pàgina de wiki" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Afegeix enllaç de wiki" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Modifica enllaç de wiki" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Borra enllaç de wiki" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Modifica projecte" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Borra projecte" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Afegeix membre" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Borra membre" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Administrar valors de projecte" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Administrar rols" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "Amo" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Arguments incomplets" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Format d'image invàlid" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "Aquest camp es obligatori." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "Projecte" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "Tipus de contingut" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "Id d'objecte" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "Data de modificació" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "Arxiu adjunt" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "està obsolet" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "Ordre" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "Whereby.com" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "Text ric" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "Menú desplegable" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "Casella de selecció" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "Número" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "tipus" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "història d'usuari" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "tasca" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "incidéncia" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Ja existix altre amb el matex nom." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "Data de venciment" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "estatus" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "tema" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "color" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "assignada a" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "requeriment de client" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "requeriment d'equip" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "referència externa" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Canvia" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Crea" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Borra" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s punts de rol" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "De" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "a" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Afegir nou arxiu" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Arxiu actualitzat" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "Obsolet" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "No obsolet" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Arxiu borrat" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "Afegit" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "Borrat" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Sense assignar" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-borrat-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "a:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "desde:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Afegit" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Canviat" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Borrat" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "afegit:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "borrat:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Desde:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "A:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "contingut" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "nota de bloqueig" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "No teniu permisos per a assignar un sprint a aquesta incidència." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "No teniu permisos per a assignar un estatus a aquesta incidència." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "No teniu permisos per a assignar una severitat a aquesta incidència." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "No teniu permisos per a assignar una prioritat a aquesta incidència." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "No teniu permisos per a assignar un tipus a aquesta incidència." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "severitat" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "prioritat" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "fita" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "Data de finalització" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "M'agrada" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Fans" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "slug" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "Data estimada d'inici" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "Data estimada de finalització" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "està tancat" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "disponibilitat" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "està bloquejat" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "email" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "token" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "text extra d'invitació" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "L'usuari ja es membre del projecte" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "estatus d'història d'usuai per defecte" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "Points per defecte" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "Estatus de tasca per defecte" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "Prioritat per defecte" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "Severitat per defecte" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "Status d'incidència per defecte" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "Tipus d'incidència per defecte" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "membres" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "total de fites" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "total de punts d'història" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "activa panell de backlog" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "activa panell de kanban" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "activa panell de wiki" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "activa panell d'incidències" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "sistema de videoconferència" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "template de creació" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "es privat" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "permisos d'anònims" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "permisos d'usuaris" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "Actualitzada data" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "configuració de mòdules" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "està arxivat" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "limit de treball en progrés" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "valor" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "rol d'amo per defecte" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "opcions per defecte" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "status d'històries d'usuari" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "punts" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "status de tasques" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "status d'incidències" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "tipus d'incidències" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "prioritats" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "severitats" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "rols" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "creada data" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Actualizada pàgina de Wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creada pàgina de Wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Borrada pàgina de Wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "Versió" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "El token no coincideix amb cap invitació vàlida." + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The project doesn't exist" +msgid "User does not exist." +msgstr "El projecte no existeix" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "L'usuari ja és membre del projecte." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "nova adreça de correu" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Token invàlid" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "tags" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "colors de tags" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "order d'històries d'usuari" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "ordre de taskboard" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "es iocaina" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "algú" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

I ara unes poques paraules del bon company o companya
que ha " +"tingut el bon pensament de convidar-te

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Acepta la invitació a Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Acepta la invitació" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"I ara unes poques paraules del bon company o companya que ha tingut el bon " +"pensament de convidar-te\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Invitació de Taiga per al projecte '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Afegit al projecte '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Nova" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Preparada" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "En curs" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Preparada per a provar" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Acabada" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Arxivada" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Tancada" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Es necessita informació" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Ajornada" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Rebutjada" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Error" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Pregunta" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Millora" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Baixa" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normal" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Alta" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Llista de suggeriments" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Menor" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Important" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Crítica" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "Experiència d'usuari" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Disseny" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Front" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Back" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Propietari del producte" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Participant" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "rol" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "ordre de backlog" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "ordre d'sprint" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "data de finalització" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "Usuaris assignats" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "generat desde incidéncia" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "swimlane" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "No hi ha cap història d'usuari amb eixe id" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "No hi ha cap projecte amb eixe id" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Rol invàlid per al projecte" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Opcions per defecte" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Estatus d'històries d'usuari" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Punts" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Estatus de tasques" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Estatus d'incidéncies" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Tipus d'incidéncies" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Prioritats" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Severitats" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Rols" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Vots" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Vot" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "últim a modificar" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Membres del projecte" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "membres" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Email duplicat" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Nom d'usuari o email invàlid" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Paràmetre de password actual requerit" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Paràmetre de password requerit" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Password actual invàlid" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Invàlid. Estás segur que el token es correcte i que no l'has usat abans?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Invàlid. Estás segur que el token es correcte?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "Correu enviat satisfactòriament!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "estatus de superusuari" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Designa que aquest usuari te tots els permisos sense asignarli-los " +"explícitament." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "mot d'usuari" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "Requerit. 30 caràcters o menys. Lletres, nombres i caràcters /./-/_" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Introdueixi un nom d'usuari vàlid." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "actiu" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Designa si aquest usuari ha de se tractac com actiu. Deselecciona açó en " +"lloc de borrar el compte." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografia" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "foto" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "data d'unió" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "data d'unió" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "llenguatge per defecte" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "zona horaria per defecte" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "coloritza tags" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "token de correu" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "nova adreça de correu" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "permissos" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "invàlid" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Nom d'usuari no vàlid. Proveu-ho amb un altre." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Personal info" +#~ msgstr "Informació personal" + +#~ msgid "Permissions" +#~ msgstr "Permissos" + +#~ msgid "Important dates" +#~ msgstr "Dates importants" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/da/LC_MESSAGES/django.po b/taiga/locale/da/LC_MESSAGES/django.po new file mode 100644 index 000000000..d4cba6859 --- /dev/null +++ b/taiga/locale/da/LC_MESSAGES/django.po @@ -0,0 +1,4783 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Bo Kjær Jensen , 2018 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-02-12 12:14+0000\n" +"Last-Translator: David Barragán \n" +"Language-Team: Danish (http://www.transifex.com/taiga-agile-llc/taiga-back/" +"language/da/)\n" +"Language: da\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "" + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "" + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "" + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "" + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "" + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "" + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "" + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "" + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "" + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "" + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "" + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Tekst" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Dato" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "" + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "slut dato" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/de/LC_MESSAGES/django.po b/taiga/locale/de/LC_MESSAGES/django.po new file mode 100644 index 000000000..fc089221a --- /dev/null +++ b/taiga/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,5793 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Carlos , 2019 +# Carsten Tornow , 2016 +# 44ecbbcef5e8eb00cef0f1e9705ca3c1_7d27cd9 , 2015 +# M S, 2015 +# Guido Brand, 2015 +# Guido Brand, 2015 +# Hans Raaf, 2015,2018 +# Hans Raaf, 2015 +# Hans Raaf, 2015,2018 +# Henning Matthaei, 2015 +# Henning Matthaei, 2015 +# Horst Maier , 2018 +# Jonas Zürcher , 2018 +# keplusplus, 2021 +# M S, 2015 +# Niklas Kopyciok , 2018 +# Niklas Kopyciok , 2018 +# Philipp Schartlmüller , 2017 +# Regina , 2015 +# Sebastian Blum , 2015 +# Silsha Fux , 2015 +# Thomas McWork , 2015 +# Thomas Rößl , 2016 +# Tobias Klepp , 2016 +# Torsten Karge , 2016 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2024-04-19 12:03+0000\n" +"Last-Translator: ssantos \n" +"Language-Team: German \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.5-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "Ungültige Loginart" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "Die Registrierung ist für die Öffentlichkeit gesperrrt." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" +"Die Nutzungsbedingungen und Datenschutzerklärung muss akzeptiert werden" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "Ungültige Registrierungsart" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" +"Der Autorisierungs-Header muss zwei durch Leerzeichen getrennte Werte " +"enthalten" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "Das angegebene Token ist für keinen Token-Typ gültig" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "Token enthielt keine erkennbare Benutzerkennung" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "Benutzer nicht gefunden" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "Benutzer ist inaktiv" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "Unbekannter Algorithmustyp '{}'" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "Sie müssen Kryptografie installiert haben, um {} verwenden zu können." + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "Ungültiger Algorithmus angegeben" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "Token ist ungültig oder abgelaufen" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "Ungültiger Benutzername" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" +"255 oder weniger Zeichen aus Buchstaben, Zahlen und Punkt, Minus oder " +"Unterstrich erforderlich." + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Ungültiger Name" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "Kein aktives Konto mit den angegebenen Anmeldeinformationen gefunden" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Der Benutzername wird schon verwendet." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Diese E-Mail Adresse wird schon verwendet." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Der Benutzer ist schon registriert." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Fehler bei der Erstellung des neuen Benutzers." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "jti" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "Benutzer" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "erstellt am" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "läuft ab am" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "Token-Sperrliste" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "Token kann nicht ohne Typ oder Lebensdauer erstellt werden" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "Token hat keine ID" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "Token hat keinen Typ" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "Token hat falschen Typ" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "Token hat keinen '{}'-Anspruch" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "Der Claim auf das Token '{}' ist abgelaufen" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "Token steht auf der Sperrliste" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Das ist ein Pflichtfeld." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Ungültiger Wert." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "Der Wert für '%s' muss entweder True/Wahr oder False/Falsch sein." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Geben Sie einen gültigen 'slug' ein, bestehend aus Buchstaben, Zahlen, " +"Unterstrichen oder Bindestrichen." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "Bitte machen Sie eine gültige Auswahl. %(value)s ist nicht verfügbar." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Ihre E-Mail-Domäne ist nicht erlaubt" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Geben Sie bitte eine gültige E-Mail Adresse an." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" +"Das Datum hat das falsche Format. Bitte verwenden Sie eines der folgenden " +"Formate: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" +"Der Datentyp 'Datetime' hat ein falsches Format. Bitte verwenden Sie eines " +"der folgenden Formate: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" +"Die Zeit hat ein falsches Format. Bitte verwenden Sie eines der folgenden " +"Formate: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Geben Sie bitte eine ganze Zahl ein." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" +"Stellen Sie sicher, dass dieser Wert niedriger oder gleich ist wie " +"%(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" +"Stellen Sie sicher, dass dieser Wert höher oder gleich ist wie " +"%(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "Der Wert für '%s' muss eine Fließkommazahl sein." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Bitte geben Sie eine Zahl ein." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "" +"Achten Sie darauf, dass insgesamt nicht mehr als %s Ziffern vorhanden sind." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "" +"Bitte stellen Sie sicher, dass nicht mehr als %s Dezimalstellen vorhanden " +"sind." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" +"Stellen Sie sicher, dass nicht mehr als %s Ziffern vor dem Dezimalpunkt " +"vorhanden sind." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" +"Es wurde keine Datei übergeben. Prüfen Sie die Kodierung der HTML-Form." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Es wurde keine Datei eingereicht." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Die eingereichte Datei ist leer." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Stellen Sie sicher, dass dieser Dateiname höchstens %(max)d Zeichen hat (er " +"hat %(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" +"Bitte senden Sie entweder eine Datei oder markieren Sie \"Löschen\", nicht " +"beides." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Bitte laden Sie ein gültiges Bild hoch. Die Datei, die Sie hochgeladen " +"haben, ist entweder kein Bild oder defekt." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Blockiertes Element" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "Seite ist nicht 'letzte', noch kann diese konvertiert werden." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Ungültige Seite (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Ungültige Berechtigungsdefinition." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Ungültige pk '%s' - Das Objekt existiert nicht." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Falsche Eingabe. Erwartet pk Wert, erhalten %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Objekt mit %s=%s existiert nicht." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Ungültiger Hyperlink - Keine passende URL" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Ungültiger Hyperlink - Falsche URL Verknüpfung" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Ungültiger Hyperlink durch Konfigurationsfehler" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Ungültiger Hyperlink - Ziel existiert nicht." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Falsche Eingabe. Erwartet url Zeichenkette, erhalten %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Ungültige Daten" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Es gab keine Eingabe" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Es können nur existierende Einträge aktualisiert werden. Eine Neuerstellung " +"ist nicht möglich." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Es wurde eine Liste von Einträgen erwartet." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Nicht gefunden" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Zugriff verweigert" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Fehler bei der Serveranmeldung" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Verbindungsfehler." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Fehlerhafte Anfrage." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Ungültige Authentifizierungsdaten." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Die Authentifizierungsdaten wurden nicht erbracht." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Sie haben keine Berechtigung, diese Aktion auszuführen." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Methode '%s' ist nicht erlaubt." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Der Accept-Header der Anfrage konnte nicht erfüllt werden" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Nicht unterstützter Medientyp '%s' in Anfrage." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Die Anfrage wurde ausgebremst." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Voraussichtlich verfügbar in %d second%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Unerwarteter Fehler" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Nicht gefunden." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Methode wird für diesen Endpunkt nicht unterstützt." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Falsche Argumente." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Fehler bei Datenüberprüfung" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Integritätsfehler wegen falscher oder ungültiger Argumente" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Voraussetzungsfehler" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Kein Raum für weitere Projekte." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s ist keine Liste" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Fehler in Filter Parameter Typen." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project' muss ein Integer-Wert sein." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Sie wurden taigatisiert" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

Wilkommen bei " +"%(product_name)s, einem Open Source, Agile Project Management Tool

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" E-Mail-Benachrichtigungen " +"verwalten oder abbestellen\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Kontaktieren Sie uns\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Folgen Sie uns auf Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Holen Sie sich den Source-Code auf GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Aktualisierungen" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Aktualisierungen" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

Kommentar:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Kommentar: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Zugriffs Fehler (Host)" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "IP Adressen Fehler" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "User-Story erstellt" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "User story geändert" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "User story gelöscht" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "Story #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Aufgabe erzeugt" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Aufgabe geändert" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Aufgabe gelöscht" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Aufgabe #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Ticket erzeugt" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Ticket geändert" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Ticket gelöscht" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Ticket: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Wiki-Seite erstellt" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Wiki-Seite geändert" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Wiki-Seite gelöscht" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Wiki-Seite: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Sprint erstellt" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Sprint geändert" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Sprint gelöscht" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Sprint: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Es ist mindestens eine Rolle nötig" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Exportdatei erforderlich" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Ungültiges Exportdatei Format" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "Fehler beim Importieren der Projektdaten" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "Fehler beim Importieren der Rollen" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "Fehler beim Importieren der Mitgliedschaften" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "Fehler beim Importieren der Listen von Projektattributen" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "Fehler beim Importieren der Standard-Projektattribute" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "Fehler beim Importieren der Kundenattribute" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "Fehler beim Import der Sprints" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "Fehler beim Importieren der Tickets" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "Fehler beim Importieren der User-Stories" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "Fehler beim Importieren der Epics" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "Fehler beim Importieren der Aufgaben" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "Fehler beim Importieren von Wiki Seiten" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "Fehler beim Importieren von Wiki Links" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "Fehler beim Importieren der Schlagworte" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "Fehler beim Importieren der Chroniken" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "unerwarteter Fehler beim Projekt-Import" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Sie können nicht mehr private Projekte haben" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" +"Sie haben Ihr aktuelles Limit für die Mitgliederanzahl für private Projekte " +"erreicht" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Mehr öffentliche Projekte können Sie nicht haben" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" +"Sie haben Ihr aktuelles Limit für die Mitgliederanzahl für öffentliche " +"Projekte erreicht" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Fehler beim Erzeugen eines Projektdumps" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"Fehler beim Laden des Dump von {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"GRUND:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"FEHLER-PFAD:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Fehler beim Laden von Projekt Export-Datei" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Fehler beim Laden Ihrer Projekt-Dump-Datei" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr " -- keine Detailinfos --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +"
Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Projekt-Export erstellt

\n" +"

Hallo %(user)s,

\n" +"

die Export-Datei des Projekts %(project)s wurde erfolgreich erstellt." +"

\n" +"

Du kannst sie hier herunterladen:

\n" +" Export-Datei herunterladen\n" +"

Die Datei wird am %(deletion_date)s gelöscht.

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(user)s,\n" +"\n" +"die Export-Datei des Projekts %(project)swurde erfolgreich erstellt. Du " +"kannst sie hier herunterladen:\n" +"\n" +"%(url)s\n" +"\n" +"Die Datei wird am %(deletion_date)s gelöscht.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] Ihre Projekt Export-Datei wurde erstellt" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Hallo %(user)s,

\n" +"

Dein Projekt %(project)s konnte nicht erfolgreich exportiert werden.\n" +"

Die Taiga-Systemadministration wurde informiert.
Bitte versuchen " +"Sie es erneut oder kontaktieren Sie das Support-Team unter\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(user)s,\n" +"\n" +"%(error_message)s\n" +"Dein Projekt %(project)s konnte nicht erfolgreich exportiert werden.\n" +"\n" +"Die Taiga-Systemadministration wurde informiert.\n" +"\n" +"Bitte versuchen Sie es erneut oder kontaktieren Sie das Support-Team unter " +"%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Hallo %(user)s,

\n" +"

Dein Projekt konnte nicht erfolgreich importiert werden.

\n" +"

Die %(product_name)s System-Administration wurde informiert.
" +"Bitte versuchen Sie es erneut oder kontaktieren Sie das Support-Team unter\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Weitere Informationen zum Fehler:

\n" +"
%(details)s
\n" +" " + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Dein Projekt konnte nicht erfolgreich importiert werden.\n" +"\n" +"Die %(product_name)s Systemadministration wurde informiert.\n" +"\n" +"Bitte versuchen Sie es erneut oder kontaktieren Sie das Support-Team unter " +"%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Projekt dump importiert

\n" +"

Hallo %(user)s,

\n" +"

Dein Projekt dump wurde erfolgreich importiert.

\n" +" Zum %(project)s wechseln\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(user)s,\n" +"\n" +"Dein Projekt dump wurde erfolgreich importiert.\n" +"\n" +"Du kannst das Projekt %(project)s unter folgender Adresse erreichen:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Ihre Projekt Export-Datei wurde importiert" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" wurde in diesem Projekt nicht gefunden" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Invalider Inhalt. Er muss wie folgt sein: {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "Enthält ungültige benutzerdefinierte Felder." + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "Duplizierter Name" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" +"Ein Epic enthält eine zugehörige Story aus einem externen Projekt " +"(%(project)s) und kann nicht importiert werden" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Authentifizierung erforderlich" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "Name" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "Icon URL" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "Web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "Beschreibung" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Nächste URL" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "Applikation" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Ungültiges Token" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "vollständiger Name" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "E-Mail Adresse" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "Kommentar" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "Erstellungsdatum" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Rückmeldungen

\n" +"

Taiga hat Rückmeldungen erhalten von %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Kommentar

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Zusätzliche Information" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- Von: %(full_name)s <%(email)s>\n" +"---------\n" +"- Kommentar:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Zusätzliche Information:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Rückmeldungen von %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "Der Payload ist kein gültiges JSON" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Das Projekt existiert nicht" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Falsche Signatur" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" +"[{user_name}]({user_url} \"Siehe {user_name}'s {platform} Profil\") " +"kommentiert in [{platform}#{number}]({comment_url} \"Gehe zum Kommentar\"):\n" +"\n" +"\"{comment_message}\"" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"Kommentar von {platform}:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Ungültige Ticket-Kommentar Information" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ticket erstellt von [{user_name}]({user_url} \"Siehe {user_name}'s " +"{platform} Profil\") von [{platform}#{number}]({url} \"Wechsle zum Ticket\")." + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Ticket erstellt durch {platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ticket von [{user_name}]({user_url} \"Siehe {user_name}'s {platform} " +"Profil\") bearbeitet von [{platform}#{number}]({url} \"Wechsle zum Ticket\")." + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "Ticket bearbeitet von {platform}." + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ticket von [{user_name}]({user_url} \"Siehe {user_name}'s {platform} " +"Profil\") der [{platform}#{number}]({url} \"Gehe zum Ticket\") geschlossen." + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "Ticket von {platform} geschlossen." + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ticket von [{user_name}]({user_url} \"Siehe {user_name}'s {platform} " +"Profil\") der [{platform}#{number}]({url} \"Gehe zum Ticket\") " +"wiedereröffnet." + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "Ticket von {platform} wiedereröffnet." + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Ungültige Ticket-Information" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "unbekannter User" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} hat den Status vom [{platform} Commit]({commit_url} \"Siehe " +"Commit '{commit_id} - {commit_short_message}'\") geändert\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"Veränderter Status von {platform} Commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"Dieser {type_name} wurde von {user_text} im [{platform} Commit]({commit_url} " +"\"Siehe Commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\" erwähnt" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "Das Ticket wurde im Commit \"{commit_message}\" auf {platform} erwähnt" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Das referenzierte Element existiert nicht" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Der Status existiert nicht" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Der Projektparameter wird benötigt" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Ungültige Asana API Anfrage" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "Request an Asana API fehlgeschlagen" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Code Parameter benötigt" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Fehler beim importieren von Asana-Projekt" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Ungültige Anmeldedaten" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "Ausfall des Dienstes eines Drittanbieters" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "Fehler beim Importieren des GitHub Projektes" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "Der URL-Parameter wird benötigt" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" +"\n" +" Es gab einen Fehler; Wahrscheinlich wegen einer nicht " +"unterstützten Jira Version.\n" +" Taiga unterstützt keine Jira Releas vor 8.6." + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "Ungültiger Projekttyp {}" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "Ungültige Jira Server Konfiguration." + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "Ungültiger oder abgelaufener Auth Token" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Fehler beim Importieren des Jira Projektes" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "Fehler beim Importieren des PivotalTracker Projektes" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Asana Projekt importiert

\n" +"

Hallo %(user)s,

\n" +"

Dein Asana Projekt wurde erfolgreich importiert.

\n" +" Zum %(project)s wechseln\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(user)s,\n" +"\n" +"Dein Asana Projekt wurde erfolgreich importiert.\n" +"\n" +"Du kannst das Projekt unter folgender Adresse %(project)s erreichen:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Dein Asana Projekt wurde erfolgreich importiert" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

GitHub Projekt importiert

\n" +"

Hallo %(user)s,

\n" +"

Dein GitHub Projekt wurde erfolgreich importiert.

\n" +" Zum %(project)s wechseln\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(user)s,\n" +"\n" +"Dein GitHub Projekt wurde erfolgreich importiert.\n" +"\n" +"Du erreichst das Projekt %(project)s unter folgender Adresse:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] Dein GitHub Projekt wurde erfolgreich importiert" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Hallo %(user)s,

\n" +"

Ihr Projekt wurde nicht korrekt importiert.

\n" +"

Die %(product_name)s Systemadministratoren wurden informiert.
" +"Bitte versuchen Sie es erneut oder wenden Sie sich an das Support-Team " +"unter\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Jira-Projekt importiert

\n" +"

Hallo %(user)s,

\n" +"

Ihr Jira-Projekt wurde korrekt importiert.

\n" +" Zu %(project)s gehen\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(user)s,\n" +"\n" +"Ihr Jira-Projekt ist korrekt importiert worden.\n" +"\n" +"Sie können das Projekt %(project)s hier sehen:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Dein Jira Projekt wurde erfolgreich importiert" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Trello Projekt importiert

\n" +"

Hallo %(user)s,

\n" +"

Ihr Trello Projekt wurde korrekt importiert.

\n" +" Zu %(project)s gehen\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(user)s,\n" +"\n" +"Ihr Trello-Projekt ist korrekt importiert worden.\n" +"\n" +"Sie können das Projekt %(project)s hier sehen:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Dein Trello Projekt wurde erfolgreich importiert" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "Ungültige Anfrage: %(text)s bei %(url)s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "Nicht autorisiert: %(text)s bei %(url)s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "Ressource nicht verfügbar: %(text)s bei %(url)s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "Fehler beim Import des Trello Projektes" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Projekt ansehen" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Meilensteine ansehen" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Epic ansehen" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "User-Stories ansehen" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Aufgaben ansehen" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Tickets ansehen" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Wiki Seiten ansehen" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Wiki Links ansehen" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Meilenstein hinzufügen" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Meilenstein ändern" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Meilenstein löschen" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Epic hinzufügen" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Epic ändern" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "Epic kommentieren" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Epic löschen" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "User-Story ansehen" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "User-Story hinzufügen" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "User-Story ändern" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "User-Story kommentieren" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "User-Story löschen" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Aufgabe hinzufügen" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Aufgabe ändern" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Aufgabe kommentieren" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Aufgabe löschen" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Ticket hinzufügen" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Ticket ändern" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "Ticket kommentieren" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Gelöschtes Ticket" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Wiki Seite hinzufügen" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Wiki Seite ändern" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Wiki-Seite kommentieren" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Wiki Seite löschen" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Wiki Link hinzufügen" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Wiki Link ändern" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Wiki Link löschen" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Projekt ändern" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Projekt löschen" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Mitglied hinzufügen" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Mitglied entfernen" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Administrator Projekt Werte" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Administrator-Rollen" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "Privatsphäre" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "Module" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Standardwerte" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "Aktivität" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "Fans" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "Besitzer" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Öffentlich machen" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} wurde erfolgreich öffentlich gemacht." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Privat machen" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} wurde erfolgreich privat gemacht." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Unvollständige Argumente" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Ungültiges Bildformat" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "Ungültiger Vorlagenname" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "Ungültige Vorlagenbeschreibung" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "Ungültige Benutzer-Id" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "Der Benutzer existiert nicht" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "Der Benutzer muss bereits ein Projektmitglied sein" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "Die Standard-Swimlane kann nicht gelöscht werden." + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" +"Sie können den Standard-Fälligkeitsstatus einer Benutzergeschichte nicht " +"löschen" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "Das Projekt hat bereits Fälligkeitstermine" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "Sie können den Standard-Fälligkeitsstatus einer Aufgabe nicht löschen" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "Das Projekt hat bereits Fälligkeitstermine für Aufgaben" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "Sie können den Standard-Fälligkeitsstatus eines Tickets nicht löschen" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "Das Projekt hat bereits Fälligkeitstermine für Tickets" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" +"Um Mitglieder zu einem Projekt hinzuzufügen, müssen Sie zunächst Ihre E-Mail-" +"Adresse verifizieren" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" +"Dieser Benutzer kann nicht aus den folgenden Projekten entfernt werden, da " +"er dann keinen aktiven Administrator mehr hätte: {}." + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "E-Mail ist erforderlich" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"Das Projekt muss einen Eigentümer haben und mindestens ein Benutzer muss ein " +"aktiver Administrator sein" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "Sie haben keine Berechtigung, das zu sehen." + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "Teil-Aktualisierungen sind nicht unterstützt" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "Das Ticket mit der Objekt-ID existiert nicht" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "Projekt-ID stimmt nicht mit Objekt und Projekt überein" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "Projekt" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "Inhaltsart" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "Objekt-ID" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "Zeitpunkt der Änderung" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "Angehangene Datei" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "SHA1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "wurde verworfen" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "vom Kommentar" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "Reihenfolge" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" +"Ungültige Anhangs-ID, zu der verschoben werden soll. Der Anhang muss zum " +"selben Element gehören (Epic, User-Story, Aufgabe, Ticket oder Wiki-Seite)." + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" +"Ungültige Anhang-IDs. Alle Anhänge müssen zum selben Element gehören (Epic, " +"User-Story, Aufgabe, Ticket oder Wikiseite)." + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "Whereby.com" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Kunde" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Gesprächig" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "Dieses Projekt ist wegen eines Zahlungsfehlers gesperrt" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "Dieses Projekt ist durch den Administrator blockiert" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "Dieses Projekt ist blockiert, weil es der Leiter verlassen hat" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "Dieses Projekt ist gesperrt, während es gelöscht wird" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +" %(full_name)s hat " +"an %(project_name)s geschrieben\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +" Sie erhalten diese Nachricht, weil Sie als Administrator für das " +"Projekt mit dem Titel %(project_name)s aufgeführt sind. Wenn Sie nicht " +"möchten, dass Mitglieder der Taiga-Community Ihr Projekt kontaktieren, " +"aktualisieren Sie bitte Ihre " +"Projekteinstellungen, um solche Kontakte zu verhindern. Die reguläre " +"Kommunikation zwischen den Mitgliedern des Projekts wird davon nicht " +"betroffen sein.\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s hat geschrieben an %(project_name)s\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +"Sie erhalten diese Nachricht, weil Sie als Administrator für das Projekt " +"%(project_name)s aufgeführt sind. Wenn Sie nicht möchten, dass Mitglieder " +"der Taiga-Gemeinschaft Ihr Projekt kontaktieren, aktualisieren Sie bitte " +"Ihre Projekteinstellungen in %(project_settings_url)s, um solche Kontakte zu " +"verhindern. Die reguläre Kommunikation zwischen den Mitgliedern des Projekts " +"ist davon nicht betroffen.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s hat eine Nachricht an das Projekt %(project_name)s " +"gesendet\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Text" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Mehrzeiliger Text" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "Rich Text" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Datum" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "Dropdown" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "Checkbox" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "Zahl" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "Art" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "Werte" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "Epic" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "User-Story" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "Aufgabe" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "Ticket" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Dieser Name wird schon verwendet." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "Fälligkeitsdatum" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "Grund des Fälligkeitsdatums" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" +"Sie haben nicht die Berechtigung, diesen Epic auf diesen Status zu setzen." + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "Status" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "Epics Reihenfolge" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "Betreff" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "Farbe" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "zugewiesen an" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "ist Kundenanforderung" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "ist Teamanforderung" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "User-Stories" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "externe Referenz" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "Es gibt keinen Epic mit dieser ID" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "ein Kommentar ist erforderlich" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "gelöschte Kommentare können nicht bearbeitet werden" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Kommentar bereits gelöscht" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Kommentar nicht gelöscht" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Ändern" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Erzeugen" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Löschen" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s Rollenpunkte" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "Von" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "An" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Neuen Anhang hinzugefügt" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Anhang aktualisiert" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "verworfen" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "nicht verworfen" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Gelöschter Anhang" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "hinzugefügt" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "entfernt" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Nicht zugewiesen" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "nicht gesetzt" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-gelöscht-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "An:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "Von:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Hinzugefügt" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Geändert" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Gelöscht" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "hinzugefügt:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "entfernt:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Von:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "An:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "Inhalt" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "Blockierungsgrund" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "Sprint" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" +"Sie haben nicht die Berechtigung, das Ticket auf diesen Sprint zu setzen." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" +"Sie haben nicht die Berechtigung, das Ticket auf diesen Status zu setzen." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" +"Sie haben nicht die Berechtigung, das Ticket auf diese Gewichtung zu setzen." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" +"Sie haben nicht die Berechtigung, das Ticket auf diese Priorität zu setzen." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Sie haben nicht die Berechtigung, das Ticket auf diese Art zu setzen." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "Gewichtung" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "Priorität" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "Meilenstein" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "Datum der Fertigstellung" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "Der Meilenstein ist für das Projekt nicht gültig" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "Alle Tickets müssen aus demselben Projekt stammen" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Like" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Likes" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "Slug" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "geschätzter Starttermin" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "geschätzter Endtermin" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "ist geschlossen" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "Verfügbarkeit" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "Der erwartete Beginn muss vor dem erwarteten Ende liegen." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "Es gibt keinen Meilenstein mit dieser ID" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "Alle User-Stories müssen aus demselben Projekt stammen" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "wird blockiert" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "Referenzangabe benötigt" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "Projekt- oder Projekt-Slug-Parameter wird benötigt" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "Der Abfrageparameter 'moveTo' ist erforderlich" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" +"Swimlane kann nicht auf None gesetzt werden, wenn es verfügbare Swimlanes " +"gibt" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' Parameter ist ein Pflichtfeld" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "Der 'project' Parameter ist ein Pflichtfeld" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "Der Benutzer muss ein Mitglied des Projektes sein." + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "E-Mail" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "erstellt am " + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "Token" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "Zusatztext für Einladung" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "Benutzerreihenfolge" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "Der Benutzer ist bereits Mitglied dieses Projekts" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "Standard Epic-Status" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "Standard-US-Status" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "voreingestellte Punkte" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "voreingestellter Aufgabenstatus" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "Standard-Priorität" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "Standard-Gewichtung" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "voreingestellter Ticket Status" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "voreingestellter Ticket Typ" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "Standard-Swimlane" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "Logo" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "Mitglieder" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "Meilensteine Gesamt" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "Story Punkte insgesamt" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "aktiver Kontakt" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "aktives Epics Panel" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "aktives Backlog Panel" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "aktives Kanban Panel" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "aktives Wiki Panel" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "aktives Tickets Panel" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "Videokonferenzsystem" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "Zusatzdaten Videokonferenz" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "Vorlage erstellen" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "ist privat" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "Rechte für anonyme Nutzer" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "Rechte für registrierte Nutzer" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "ist gekennzeichnet" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "sucht nach Mitarbeitern" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "Hinweis für Mitarbeitersuche" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "Projekt-Transfer-Token" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "Blockierter Code" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "Aktualisierungsdatum" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "Count" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "Unterstützer letzte Woche" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "Unterstützer letzten Monat" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "Unterstützer letztes Jahr" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "Aktivitäten letzte Woche" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "Aktivitäten letzten Monat" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "Aktivitäten letztes Jahr" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "Module konfigurieren" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "ist archiviert" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "Ausführungslimit" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "Wert" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "Standardmässig" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "Tage bis zur Fälligkeit" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "Status der User-Story" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "voreingestellte Besitzerrolle" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "Vorgabe Optionen" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "Epic Status" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "US-Status" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "US Fälligkeiten" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "Punkte" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "Aufgaben Status" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "Aufgaben Fälligkeiten" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "Ticket Status" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "Ticket Arten" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "Ticket Fälligkeiten" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "Prioritäten" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "Gewichtung" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "Rollen" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "Benutzerdefinierte Epic Attribute" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "Benutzerdefinierte User Story Attribute" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "Benutzerdefinierte Aufgaben Attribute" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "Benutzerdefinierte Ticket Eigenschaften" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Beteiligt" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Alle" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Keine" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "Zugewiesen" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "Erwähnt" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "Als Beobachter hinzugefügt" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "Als Mitglied hinzugefügt" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "Kommentar" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "Im Kommentar erwähnt" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "Erstelldatum" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "Chronik Einträge" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "Benutzer benachrichtigen" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Beobachtet" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Benachrichtigung für bestimmte Benutzer und Projekt aktiviert" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Ungültiger Wert für Benachrichtigungslevel" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" +"\n" +"

Epic wurde aktualisiert

\n" +"

Hallo %(user)s,
%(changer)s hat ein Epic auf %(project)s " +"aktualisiert

\n" +"

Epic #%(ref)s %(subject)s

\n" +" Epos ansehen\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"Epic aktualisiert\n" +"Hallo %(user)s, %(changer)s hat ein Epic auf %(project)s aktualisiert\n" +"Siehe Epic #%(ref)s %(subject)s unter %(url)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Das Epic #%(ref)s \"%(subject)s\" wurde aktualisiert\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Neues Epic erstellt

\n" +"

Hallo %(user)s,
%(changer)s hat ein neues Epic auf %(project)s erstellt\n" +"

Epic #%(ref)s %(subject)s

\n" +" Epic ansehen\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Neues Epic erstellt\n" +"Hallo %(user)s, %(changer)s hat ein neues Epic auf %(project)s erstellt\n" +"Siehe Epic #%(ref)s %(subject)s unter %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] erzeugte das Epic #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Epic gelöscht

\n" +"

Hallo %(user)s,
%(changer)s hat ein Epic auf %(project)s

" +"gelöscht\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Epic gelöscht\n" +"Hallo %(user)s, %(changer)s hat ein Epic auf %(project)s gelöscht\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Das Epic #%(ref)s \"%(subject)s\" wurde gelöscht\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" +"\n" +"

Aktualisiertes Ticket auf %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat ein Ticket aktualisiert:

\n" +"

#%(ref)s %(subject)s

\n" +" Ticket ansehen\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" +"\n" +"Ticket aktualisiert am %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat ein Ticket aktualisiert:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Siehe Ticket unter %(url)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] aktualisierte das Ticket #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Neues Ticket erstellt am %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat ein neues Ticket erstellt:

\n" +"

#%(ref)s %(subject)s

\n" +" Ticket ansehen\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Neues Ticket erstellt am %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat ein neues Ticket erstellt:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Siehe Ticket unter %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] erstellte das Ticket #%(ref)s \"%(subject)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Ticket gelöscht am %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat ein Ticket gelöscht:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Ticket gelöscht am %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat ein Ticket gelöscht:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] löschte das Ticket #%(ref)s \"%(subject)s\"\n" +" \n" +"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" +"\n" +"

Sprint aktualisiert auf %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat einen Sprint aktualisiert:

\n" +"

%(name)s

\n" +" Sprint ansehen\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" +"\n" +"Sprint aktualisiert am %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat einen Sprint aktualisiert:\n" +"\n" +"%(name)s\n" +"\n" +"Siehe Sprint unter %(url)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] aktualisierte den Sprint \"%(milestone)s\"\n" +" \n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Neuer Sprint erstellt am %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat einen neuen Sprint erstellt

\n" +"

%(name)s

\n" +" Sprint ansehen\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Neuer Sprint erstellt am %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat einen neuen Sprint erstellt:\n" +"\n" +"%(name)s\n" +"\n" +"Siehe Sprint unter %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] erstellte den Sprint \"%(milestone)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Sprint gelöscht am %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat einen Sprint gelöscht:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Sprint gelöscht auf %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat einen Sprint gelöscht:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] löschte den Sprint \"%(milestone)s\"\n" +" \n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" +"\n" +"

Aufgabe aktualisiert auf %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat eine Aufgabe aktualisiert:

\n" +"

#%(ref)s %(subject)s

\n" +" Siehe Aufgabe\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" +"\n" +"Aufgabe aktualisiert auf %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat eine Aufgabe aktualisiert:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Siehe Aufgabe unter %(url)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] aktualisierte die Aufgabe #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Neue Aufgabe erstellt am %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat eine neue Aufgabe erstellt:

\n" +"

#%(ref)s %(subject)s

\n" +" Siehe Aufgabe\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Neue Aufgabe erstellt auf %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat eine neue Aufgabe erstellt:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Siehe Aufgabe unter %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] erstellte die Aufgabe #%(ref)s \"%(subject)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Aufgabe gelöscht am %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat eine Aufgabe gelöscht:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Aufgabe gelöscht am %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat eine Aufgabe gelöscht:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] hat die Aufgabe gelöscht #%(ref)s \"%(subject)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" +"\n" +"

User-Story aktualisiert auf %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat eine User-Story aktualisiert:\n" +"

#%(ref)s %(subject)s

\n" +" Siehe User-Story\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" +"\n" +"User-Story aktualisiert auf %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat eine User-Story aktualisiert:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Siehe User-Story unter %(url)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] aktualisierte die User-Story #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Neue User-Story erstellt am %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat eine neue User-Story erstellt:\n" +"

#%(ref)s %(subject)s

\n" +" Siehe User-Story\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Neue User-Story erstellt am %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat eine neue User-Story erstellt:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Siehe User-Story unter %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] erstellte die User-Story #%(ref)s \"%(subject)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

User-Story gelöscht am %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat eine User-Story gelöscht:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"User-Story gelöscht am %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat eine User-Story gelöscht\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] löschte die User-Story #%(ref)s \"%(subject)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" +"\n" +"

Wikiseite aktualisiert auf %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat eine Wikiseite aktualisiert:\n" +"

%(page)s

\n" +" Siehe " +"Wikiseite\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" +"\n" +"Wikiseite aktualisiert auf %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat eine Wikiseite aktualisiert:\n" +"\n" +"%(page)s\n" +"\n" +"Siehe Wikiseite unter %(url)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] aktualisierte die Wiki Seite \"%(page)s\"\n" +" \n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Neue Wikiseite erstellt auf %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat eine neue Wikiseite erstellt:\n" +"

%(page)s

\n" +" Siehe " +"Wikiseite\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Neue Wiki-Seite erstellt Seite auf %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat eine neue Wiki-Seite erstellt:\n" +"\n" +"%(page)s\n" +"\n" +"Siehe Wiki-Seite bei %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] erstellte die Wiki Seite \"%(page)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Wiki-Seite gelöscht auf %(project)s

\n" +"

Hallo %(user)s,
%(changer)s hat eine Wikiseite gelöscht:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Wikiseite gelöscht am %(project)s\n" +"\n" +"Hallo %(user)s, %(changer)s hat eine Wikiseite gelöscht:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] löschte die Wiki Seite \"%(page)s\"\n" +"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Beobachter enthält ungültige Benutzer" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "Die Watcher beinhalten einen ungültigen Benutzer" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "Der Versionsparameter ist ungültig" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Die Version stimmt nicht mit der aktuellen überein" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "Version" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"Sie können das Projekt nicht verlassen, wenn Sie der Leiter sind oder wenn " +"keine weiteren Administratoren vorhanden sind" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "Das Token kann keiner gültigen Einladung zugeordnet werden." + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "Der Benutzer existiert nicht." + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Dieser Benutzer ist schon ein Mitglied des Projektes." + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "Fehlerhafte E-Mail-Adresse." + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Projekt ohne Eigentümer" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" +"Sie haben Ihr aktuelles Limit für die Mitgliederanzahl für private Projekte " +"erreicht" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" +"Sie haben Ihr aktuelles Limit für die Mitgliederanzahl für öffentliche " +"Projekte erreicht" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "Sie haben Ihr aktuelles Limit für offene Mitgliedschaften erreicht" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "Aufgabe #%(ref)s" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Zukünftiger Sprint" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Projektende" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Token ist ungültig" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Token ist abgelaufen" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "Zeitleiste" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "Epics" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "Backlog" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "Probleme" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "TeamWiki" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "Sie haben keinen Zugriff auf diesen Bereich" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" +"Ungültige Eingabe '{value}'. Diese Farbe ist keine gültige HEX Farbe oder " +"null." + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"Ungültiges Tag '{value}'. Es muss der Name oder ein Paar '[\"name\", \"hex " +"color/\" | null]' sein." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "Ungültiges Tag '{value}'. Es muss der Tag-Name sein." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "Tags" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "Tag Farben" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Dieser Tag existiert bereits·" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "Diese Farbe ist keine gültige HEX Farbe." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "Dieses Tag existiert nicht." + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" +"Sie haben nicht die Berechtigung, diesen Sprint auf diese Aufgabe zu setzen." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" +"Sie haben nicht die Berechtigung, diese User-Story auf diese Aufgabe zu " +"setzen." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" +"Sie haben nicht die Berechtigung, diesen Status auf diese Aufgabe zu setzen." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "us-Reihenfolge" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "Aufgabenlisten-Reihenfolge" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "ist Iocaine" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "Ungültige milestone id." + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "Ungültige Aufgaben Status Id." + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "Ungültige user story id." + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" +"Ungültige Aufgabenstatus-Id. Der Status muss zum selben Projekt gehören." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" +"Ungültige User-Story-ID. Die User-Story muss zum selben Projekt gehören." + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" +"Ungültige Meilenstein-ID. Der Meilenstein muss zu demselben Projekt gehören." + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" +"Ungültige Aufgaben-ID. Alle Aufgaben müssen zum selben Projekt und, falls " +"vorhanden, zum selben Status, User-Story und/oder Meilenstein gehören." + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "Alle Aufgaben müssen aus demselben Projekt stammen" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "jemand" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" +"\n" +"

Sie wurden zu %(product_name)s eingeladen!

\n" +"

Hallo! %(full_name)s hat Ihnen eine Einladung zur Teilnahme am Projekt " +"%(project)s in %(product_name)s gesendet.
Taiga ist ein Open-" +"Source-Tool für agiles Projektmanagement. Als Taiga-Benutzer entstehen Ihnen " +"keine Kosten.

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

Und nun ein paar Worte von dem oder den guten Freund/en
, " +"die so freundlich waren, Sie einzuladen

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Ihre Einladung zu Taiga annehmen" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Ihre Einladung annehmen" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" +"\n" +"Sie oder jemand, den Sie kennen, hat Sie zu %(product_name)s eingeladen\n" +"\n" +"Hallo! %(full_name)s hat Ihnen eine Einladung geschickt, an einem Projekt " +"namens %(project)s in %(product_name)s teilzunehmen.\n" +"Taiga ist ein Open Source Agile Project Management Tool. Es kostet Sie " +"nichts, Taiga zu benutzen.\n" +"\n" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"Und nun ein paar Worte vom guten Freund, der so freundlich war, Sie " +"einzuladen\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Nehmen Sie Ihre Einladung zu Taiga an, indem Sie diesem Link folgen:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Einladung zur Teilnahme am Projekt '%(project)s'\n" +"\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Sie wurden zu einem Projekt hinzugefügt

\n" +"

Hallo %(full_name)s,
Sie wurden zu dem Projekt %(project)s

" +"hinzugefügt\n" +" Zum " +"Projekt gehen\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Sie sind zu einem Projekt hinzugefügt worden\n" +"\n" +"Hallo %(full_name)s, Sie wurden dem Projekt %(project)s hinzugefügt\n" +"\n" +"Siehe Projekt unter %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +" [Taiga] Zum Projekt hinzugefügt '%(project)s'\n" +" \n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Hallo %(old_owner_name)s,

\n" +"

%(new_owner_name)s hat Ihr Angebot angenommen und wird der neue " +"Projektleiter von \"%(project_name)s\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s sagt:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

Ab jetzt ist Ihr neuer Status für dieses Projekt \"admin\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"Hallo %(old_owner_name)s\n" +"%(new_owner_name)s hat Ihr Angebot akzeptiert und wird der neue " +"Projektleiter von \"%(project_name)s\".\n" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s sagt:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"Ab sofort ist Ihr neuer Status für dieses Projekt \"admin\".\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s Angebot zum Projektleiter-Transfer ist akzeptiert!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Hallo %(owner_name)s,

\n" +"

%(rejecter_name)s hat Ihr Angebot abgelehnt und wird nicht der " +"neue Projektleiter von \"%(project_name)s\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s sagt:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

Wenn Sie wollen, können Sie immer noch versuchen, die Projektleitung " +"auf eine andere Person zu übertragen.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "Übertragung auf eine andere Person anfordern" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"Hallo %(owner_name)s\n" +"%(rejecter_name)s hat Ihr Angebot abgelehnt und wird nicht der neue " +"Projektleiter von \"%(project_name)s\".\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s sagt:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"Wenn Sie wollen, können Sie immer noch versuchen, die Projektleitung an eine " +"andere Person zu übertragen.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "Anfrage zur Übertragung auf eine andere Person:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] Projekt-Leitungsübertragung abgelehnt\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Hallo %(owner_name)s,

\n" +"

%(requester_name)s hat beantragt, der Projektleiter von \"%(project_name)" +"s\" zu werden.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

Bitte klicken Sie auf \"Weiter\", wenn Sie den Projekttransfer " +"vom Administrationspanel starten möchten.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Fortsetzen" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" +"\n" +"Hallo %(owner_name)s\n" +"%(requester_name)s hat beantragt, der Projektleiter von \"%(project_name)s\" " +"zu werden.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"Bitte gehen Sie zu Ihren Projekteinstellungen, wenn Sie den Projekttransfer " +"vom Administrationspanel aus starten möchten.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Geben Sie zu Ihren Projekt-Einstellungen:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s Antrag auf Übertragung des Projekts\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

Hallo %(receiver_name)s,

\n" +"

%(owner_name)s, der aktuelle Projektleiter bei \"%(project_name)" +"s\" möchte, dass Sie der neue Projektleiter werden.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s sagt:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

Bitte klicken Sie auf \"Weiter\", um diesen Vorschlag zu " +"akzeptieren oder abzulehnen.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"Hallo %(receiver_name)s\n" +"%(owner_name)s, der aktuelle Projektleiter von \"%(project_name)s\" möchte, " +"dass Sie der neue Projektleiter werden.\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s sagt:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"Bitte gehen Sie auf den folgenden Link, um diesen Vorschlag entweder zu " +"akzeptieren oder abzulehnen.

\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "Akzeptieren oder lehnen Sie den Projektleitungstransfer ab:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s Angebot der Übertragung der Projektleitung\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Das agile Product Backlog in Scrum ist eine priorisierte Feature-Liste, die " +"Kurzbeschreibungen aller gewünschten Funktionalitäten eines Produktes " +"enthält. Wenn Sie Scrum anwenden, ist es nicht erforderlich, eine " +"langwierige Aufwandsschätzung durchzuführen. Das Product Backlog in Scrum " +"darf stetig wachsen und geändert werden, sodass es den Anforderungen des " +"Produktes und seiner Kunden gerecht wird." + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban ist eine Methode der Produktionsprozesssteuerung. Das Vorgehen " +"orientiert sich ausschließlich am tatsächlichen Verbrauch von Materialien am " +"Bereitstell- und Verbrauchsort. Kanban ermöglicht eine Reduktion der lokalen " +"Bestände von Vorprodukten in und nahe der Produktion, die dort in Produkten " +"der nächsten Integrationsstufe verbaut werden." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Neu" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Bereit" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "In Arbeit" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Bereit zum Testen" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Erledigt" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Archiviert" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Geschlossen" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Information wird benötigt" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Verschoben" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Zurückgewiesen" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Fehler" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Frage" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Erweiterung" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Niedrig" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normal" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Hoch" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Wunschliste" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Gering" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Wichtig" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Kritisch" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Design" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Front" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Back" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Projekteigentümer" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Stakeholder" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"Sie haben nicht die Berechtigung, diesen Sprint auf diese User-Story zu " +"setzen." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" +"Sie haben nicht die Berechtigung, diesen Status auf diese User-Story zu " +"setzen." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" +"Sie haben keine Berechtigung, diese Swimlane auf diese User-Story zu setzen." + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "Ungültige Rollen-ID '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "Ungültige Punkte-ID '{points_id}'" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Erstelle die User-Story #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "Rolle" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "Backlog-Reihenfolge" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "Sprintreihenfolge" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "kanban Reihenfolge" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "Endtermin" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "zugewiesene Benutzer" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "erzeugt aus Ticket" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "erzeugt von der Aufgabe" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "Referenz der Aufgabe" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "Swimlane" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Es gibt keine User-Story mit dieser id" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"Ungültige User-Story Status-ID. Der Status muss zum gleichen Projekt gehören." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "Ungültige Swimlane. Die Swimlane muss zu demselben Projekt gehören." + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"Ungültige User-Story-ID, zu der verschoben werden soll. Die User-Story muss " +"zum selben Projekt und Meilenstein gehören." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "Sie können nach und vor nicht gleichzeitig verwenden." + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"Ungültige User-Story-id, um nach davor zu bewegen. Die User-Story muss zu " +"demselben Projekt und Meilenstein gehören." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" +"Ungültige User Story-IDs. Alle User-Storys müssen zum selben Projekt gehören." + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"User-Story-ID zum dahinter verschieben ist ungültig. Die Benutzergeschichte " +"muss zu demselben Projekt, Status und Swimlane gehören." + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"User-Story-ID zum davor verschieben ist ungültig. Die Benutzergeschichte " +"muss zu demselben Projekt, Status und Swimlane gehören." + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Es gibt kein Projekt mit dieser id" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "Der Benutzer existiert noch im Projekt" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "Ungültiger Vorgang" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Ungültige Rolle für dieses Projekt" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "Der Benutzer muss ein gültiger Kontakt sein" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "Der Projekteigentümer muss Administrator sein." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" +"Mindestens ein Benutzer muss ein aktiver Administrator des Projektes sein." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" +"Ungültige Rollen-IDs. Alle Rollen müssen dem gleichen Projekt angehören." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Voreingestellte Optionen" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Status für User-Stories" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Punkte" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Aufgaben Status" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Ticket Status" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Ticket Arten" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Prioritäten" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Gewichtung" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Rollen" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Stimmen" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Stimme" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content' Parameter ist erforderlich" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id' Parameter ist erforderlich" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "letzte Änderung" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "Instanzen-ID" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Prüfe die API der Historie auf Übereinstimmung" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Projektmitglied" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Projektmitglieder" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "Id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Projekt-Besitz" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Projekt-Besitze" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "Mitgliedschaften" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "PERSÖNLICHE INFO" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "ZUSATZ-INFO" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "BERECHTIGUNGEN" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "WICHTIGE TERMINE" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "PROJEKT LEITUNGSEINSCHRÄNKUNGEN" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "PROJEKT LEITUNGSEINSSTATISTIKEN" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "Geleitete private Projekte" + +#: taiga/users/admin.py:175 +#, fuzzy +msgid "Private memberships owned" +msgstr "Persönliche Mitgliedschaften" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "Geleitete öffentliche Projekte" + +#: taiga/users/admin.py:199 +#, fuzzy +msgid "Public memberships owned" +msgstr "Öffentliche Mitgliedschaften" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Doppelte E-Mail" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "Ungültige E-Mail" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Ungültiger Benutzername oder E-Mail" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "E-Mail erfolgreich gesendet!" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Aktueller Passwort Parameter wird benötigt" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Neuer Passwort Parameter benötigt" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "Ungültige Passwortlänge, mindestens 6 Zeichen erforderlich" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Ungültiges aktuelles Passwort" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Ungültig. Sind Sie sicher, dass das Token korrekt ist und Sie es nicht " +"bereits verwendet haben?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Ungültig. Sind Sie sicher, dass das Token korrekt ist?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "E-Mail-Adresse bereits bestätigt" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "Nicht in der Lage, diese E-Mail-Adresse zu bestätigen" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "E-Mail erfolgreich gesendet!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "Superuser Status" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Gibt an, dass dieser Benutzer über alle Berechtigungen verfügt, ohne dass " +"diese zuvor zugewiesen wurden." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "Benutzername" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" +"Benötigt. 30 Zeichen oder weniger.. Buchstaben, Zahlen und /./-/_ Zeichen" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Geben Sie einen gültigen Benuzternamen ein." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "aktiv" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Kennzeichnet den Benutzer als aktiv. Deaktiviere die Option anstelle einen " +"Benutzer zu löschen." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "Personalstatus" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "Bezeichnet, ob sich der Benutzer an dieser Admin-Website anmelden kann." + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "Über mich" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "Foto" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "Beitrittsdatum" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "Datum der Stornierung" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "akzeptierte Bedingungen" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "neue Bedingungen gelesen" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "Vorgegebene Sprache" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "Standard-Theme" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "Vorgegebene Zeitzone" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "Tag-Farben" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "E-Mail Token" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "neue E-Mail Adresse" + +#: taiga/users/models.py:166 +#, fuzzy +msgid "max number of owned private projects" +msgstr "max. Anzahl persönlicher Projekte" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "max. Anzahl der eigenen öffentlichen Projekte" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" +"max. Anzahl der Mitgliedschaft verschiedener Nutzer für jedes persönliche " +"Projekt" + +#: taiga/users/models.py:177 +#, fuzzy +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" +"max. Anzahl der Mitgliedschaft verschiedener Benutzer für alle im Besitz " +"befindlichen öffentlichen Projekte" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "Berechtigungen" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Benutzername oder Passwort stimmen mit keinem Benutzer überein." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Ändern Sie Ihre E-Mail

\n" +"

Hallo %(full_name)s,
bitte bestätigen Sie Ihre E-Mail

\n" +" E-Mail " +"bestätigen\n" +"

Sie können diese Nachricht ignorieren, wenn Sie sie nicht " +"angefordert haben.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(full_name)s, bitte bestätigen Sie Ihre E-Mail\n" +"\n" +"%(url)s\n" +"\n" +"Sie können diese Nachricht ignorieren, wenn Sie sie nicht angefordert haben." +"\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] E-Mail ändern" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Ihr Passwort wiederherstellen

\n" +"

Hallo %(full_name)s,
Sie baten, Ihr Passwort " +"wiederherzustellen

\n" +" Ihr Passwort wiederherstellen\n" +"

Sie können diese Nachricht ignorieren, wenn Sie sie nicht " +"angefordert haben.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(full_name)s, Sie baten, Ihr Passwort wiederherzustellen\n" +"\n" +"%(url)s\n" +"\n" +"Sie können diese Nachricht ignorieren, wenn Sie nicht anforderten.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Passwort-Wiederherstellung" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" +"\n" +"

Bitte bestätigen Sie Ihre E-Mail

\n" +" E-Mail bestätigen\n" +" " + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" +"\n" +"

Danke für die Registrierung in %(product_name)s

\n" +"

Wir sind begeistert, dass Sie unserer wachsenden Community " +"von Fachleuten beigetreten sind, die ihre Arbeitsweise revolutionieren und " +"hoffen, dass Sie es genießen.

\n" +"\n" +"

Noch Fragen? Geben Sie uns ein Hinweis, wenn Sie Hilfe " +"benötigen, wir sind unter %(support_email)s erreichbar.

\n" +"

%(signature)s

\n" +" \n" +" " + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" Sie können Ihr Konto von diesem Dienst entfernen indem Sie hier klicken\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" +"\n" +"Vielen Dank für die Anmeldung in %(product_name)s\n" +"\n" +"Wir freuen uns, dass Sie sich unserer wachsenden Fachgemeinschaft " +"angeschlossen haben, die ihre Arbeitsweise revolutioniert und hofft, dass " +"Sie es genießen.\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" +"\n" +"Bitte bestätigen Sie Ihre E-Mail: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" +"\n" +"Haben Sie Fragen? Geben Sie uns einen Hinweis, wenn Sie Hilfe benötigen, wir " +"sind unter %(support_email)s erreichbar.\n" +"--\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Sie können Ihren Account von diesem Dienst trennen: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Sie wurden taigatisiert!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Ihre E-Mail bestätigen

\n" +"

Hello %(full_name)s,
Bitte überprüfen Sie Ihre E-Mail

\n" +" E-Mail " +"bestätigen\n" +"

Sie können diese Nachricht ignorieren, wenn Sie sie nicht " +"anforderten.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hallo %(full_name)s, bitte überprüfen Sie Ihre E-Mail\n" +"\n" +"%(url)s\n" +"\n" +"Sie können diese Nachricht ignorieren, wenn Sie sie nicht anforderten.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "[Taiga] E-Mail überprüfen" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "ungültig" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Ungültiger Benutzername. Versuchen Sie es mit einem anderen." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "Neue Bedingungen gelesen muss wahr sein'" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"Doppelter Schlüsselwert verstößt einzigartige Vorgaben. Schlüssel '{}' " +"existiert bereits." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "Schlüssel" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "Geheimer Schlüssel" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "Status Code" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "Anfrage Daten" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "Anfrage Header" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "Antwort Daten" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "Antwort Header" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "Dauer" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "IP-Adresse nicht erlaubt" + +#~ msgid "Personal info" +#~ msgstr "Personal Information" + +#~ msgid "Permissions" +#~ msgstr "Berechtigungen" + +#~ msgid "Restrictions" +#~ msgstr "Einschränkungen" + +#~ msgid "Important dates" +#~ msgstr "Wichtige Termine" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/en/LC_MESSAGES/django.po b/taiga/locale/en/LC_MESSAGES/django.po new file mode 100644 index 000000000..f7b27f91e --- /dev/null +++ b/taiga/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,4783 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Hotellook, 2014 +# Matjaž Mozetič , 2015 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2020-02-21 11:26+0100\n" +"Last-Translator: Miguel Gonzalez \n" +"Language-Team: English (http://www.transifex.com/taiga-agile-llc/taiga-back/" +"language/en/)\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "" + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "" + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "" + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "" + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "" + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "" + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "" + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "" + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "" + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "" + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "" + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "" + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/es/LC_MESSAGES/django.po b/taiga/locale/es/LC_MESSAGES/django.po new file mode 100644 index 000000000..d8da9be6d --- /dev/null +++ b/taiga/locale/es/LC_MESSAGES/django.po @@ -0,0 +1,5757 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# David Barragán , 2015-2017,2021 +# Esther Moreno , 2015 +# Gustavo Díaz Jaimes , 2015 +# Gustavo Díaz Jaimes , 2015 +# Hector Colina , 2015 +# Ion Jaureguialzo Sarasola , 2019 +# Jesús , 2015 +# Jesús , 2015 +# Jorge Sanchez , 2016 +# José Alejandro Díaz Carmona , 2016 +# Kiko Fernandez-Reyes , 2016 +# Leonardo J. Caballero G. , 2020 +# Libre Beats , 2020 +# Luis Sebastian Urrutia Fuentes , 2016 +# Miguel Gonzalez , 2018-2020 +# Renelis Abreu Ramirez , 2016 +# Taiga Dev Team , 2015-2016 +# Xaviju , 2016 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2023-03-10 13:37+0000\n" +"Last-Translator: gallegonovato \n" +"Language-Team: Spanish \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.16.2-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "Tipo de login inválido" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "El registro público está deshabilitado." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" +"Por favor, acepta nuestras condiciones de servicio y política de privacidad" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "tipo de registro inválido" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" +"La cabecera de autorización debe contener dos valores delimitados por " +"espacios" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "El token dado no es válido para ningún tipo de token" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "El token no contenía ninguna identificación de usuario reconocible" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "Usuario no encontrado" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "El usuario está inactivo" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "Tipo de algoritmo '{}' no reconocido" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "Debe tener instalada la criptografía para utilizar {}." + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "Algoritmo especificado no válido" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "Token no válido o caducado" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "nombre de usuario no válido" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Son necesarios. 255 caracteres o menos (letras, números y /./-/_)" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "nombre completo no válido" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" +"No se ha encontrado ninguna cuenta activa con las credenciales indicadas" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "El nombre de usuario ya está en uso." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "El correo electrónico ya se utiliza." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Este usuario ya está registrado." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Error al crear un nuevo usuario." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "jti" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "usuario" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "creado el" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "expira el" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "Lista de denegación de tokens" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "No se puede crear un token sin tipo o tiempo de vida" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "El token no tiene identificador" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "El token no tiene tipo" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "El token es incorrecto" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "El token no tiene reclamo '{}'" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "El token '{}' ha caducado" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "Se niega el token" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Este campo es requerido." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Valor inválido." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "El valor para '%s' debe ser True o False." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Escribe un slug válido que esté formado por letras, números o los símbolos " +"de guión o subrayado." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"Seleccione una opción válida. %(value)s no es una de las opciones " +"disponibles." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "El dominio de tú email no es válido" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Introduzca una dirección de email válida." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" +"La fecha posee un formato inválido. Utiliza alguno de los siguientes " +"formatos: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" +"La fecha y hora poseen un formato inválido. Utiliza alguno de los siguientes " +"formatos: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" +"El tiempo indicado posee un formato inválido. Utiliza alguno de los " +"siguientes formatos: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Introduzca un número entero." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Asegúrate de que el valor es menor o igual a %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Asegúrate de que el valor es mayor o igual a %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "El valor \"%s\" debe ser un número en coma flotante." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Introduce un número." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Asegúrate de que no haya más de %s dígitos en total." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Asegúrate de que no haya más de %s decimales." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" +"Asegúrate de que no haya más de %s dígitos en la parte entera del número." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" +"No se ha adjuntado ningún archivo. Comprueba el encoding en el formulario." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "No se envió ningún archivo." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "El archivo enviado está vacío." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Asegúrate de que el nombre del fichero contiene menos de %(max)d caracteres " +"(ahora tiene %(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "Por favor, adjunta un fichero o marca la casilla de vacío, no ambos." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "Adjunta una imagen válida. El fichero no es una imagen o está dañada." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Elemento bloqueado" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "La página no es 'last' o no es un número." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Página no válida (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Definición de permiso inválida." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "PK '%s' inválida - el objeto no existe." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" +"Tipo incorrecto. Se esperaba un identificador (pk) y se ha recibido %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "El objeto con %s=%s no existe." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Hipervínculo inválido - La URL no encaja con ningun objeto" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Hipervínculo inválido - La URL es incorrecta" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Hipervínculo inválido debido a un error de configuración" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Hipervínculo inválido - el objeto no existe." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Tipo incorrecto. Se esperaba una url y se ha recibido %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Datos invalidos" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "No se han facilitado datos" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"No se pueden crear nuevos objetos. Sólo está permitida la actualización de " +"los existentes." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Se esperaba una lista de objetos." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "No encontrado" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Permiso denegado" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Error en la aplicación del servidor" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Error de conexión." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Petición con formato incorrecto." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Credenciales de autenticación incorrectas." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "No se han proporcionado las credenciales de autenticación." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "No tienes permisos para realizar esta acción." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Método '%s' no permitido." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "No se ha podido satisfacer la perición de cabecera Accept" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Típo de medio '%s' no soportado." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Demasiadas peticiones." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Estará disponible en %d segundos%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Error inesperado" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "No encontrado." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Método no soportado por este recurso." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Argumentos erróneos." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Error de validación de datos" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Error de integridad por argumentos incorrectos o inválidos" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Error por incumplimiento de precondición" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "No hay espacio para más proyectos." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s no es una lista" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Error en los tipos de parámetros del filtro." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project' debe ser un valor entero." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Te hemos Taigaizado" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

Bienvenido a " +"%(product_name)s, una herramienta ágil de gestión de proyectos de código " +"abierto

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" Configura dirección de " +"correo o darse de baja\n" +"  • \n" +" Soporte de Taiga\n" +"  • \n" +" Contacta con nosotros\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Síguenos en Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Copia el código en GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Actualizaciones" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Actualizaciones" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

comentario:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Comentario: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Error al acceder al host" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "Dirección IP errónea" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "Historia de usuario creada" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "Historia de usuario modificada" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "Historia de usuario borrada" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "Historia de usuario #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Tarea creada" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Tarea actualizada" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Tarea eliminada" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Tarea #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Petición creada" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Petición actualizada" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Petición borrada" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Petición: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Página wiki creada" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Página wiki actualizada" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Página wiki borrada" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Página wiki: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Sprint creado" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Sprint actualizado" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Sprint borrado" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Sprint: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Necesitamos al menos un rol" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Se necesita el fichero con los datos exportados" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Formato de fichero de exportación inválido" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "error importando los datos del proyecto" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "error importando los roles" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "error importando los miembros" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "error importando la listados de valores de attributos del proyecto" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "error importando los valores por defecto de los atributos del proyecto" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "error importando los atributos personalizados" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "error importando los sprints" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "error importando las peticiones" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "error importando las historias de usuario" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "error importando epics" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "error importando las tareas" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "error importando las páginas del wiki" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "error importando los enlaces del wiki" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "error importando los tags" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "error importando los timelines" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "Error inesperado al importar el proyecto" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "No puedes tener más proyectos privados" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" +"Este proyecto alcanzo el limite actual de miembros para proyectos privados" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "No puedes tener más proyectos públicos" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" +"Este proyecto alcanzo su limite actual de miembros para proyectos publicos" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Erro generando el volcado de datos del proyecto" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"Error cargando importacion {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Error cargando el volcado de datos del proyecto" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Error cargando el archivo del proyecto exportado" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr " -- sin información detallada --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +"
Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Volcado de datos de proyecto generado

\n" +"

Hola %(user)s,

\n" +"

El volcado de datos de tu proyecto %(project)s se ha generado con " +"éxito.

\n" +"

Puedes descargarlo aquí:

\n" +" Descargar el archivo con el volcado de datos\n" +"

Este archivo se borrará en %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(user)s,\n" +"\n" +"El volcado de datos de tu proyecto %(project)s se ha generado con éxito. " +"Puedes descargarlo aquí:\n" +"\n" +"%(url)s\n" +"\n" +"Este archivo se borrará en %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" +"[%(project)s] Se ha generado el fichero con el volcado de datos de tu " +"proyecto" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Hola %(user)s,

\n" +"

Su proyecto %(project)s no se pudo exportar correctamente.

\n" +"

Se ha informado a la administración del sistema Taiga.
Vuelva a " +"intentarlo o póngase en contacto con el equipo de soporte en\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(user)s,\n" +"\n" +"%(error_message)s\n" +"Tu proyecto %(project)s no se ha exportado correctamente.\n" +"\n" +"Se ha informado del error a los administradores del sistema Taiga.\n" +"\n" +"Por favor, inténtalo de nuevo o contacta con el equipo de soporte en " +"%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Hola %(user)s,

\n" +"

Su proyecto no se pudo importar con éxito.

\n" +" Se ha informado a la administración del sistema de

%(product_name)s." +"
Vuelva a intentarlo o póngase en contacto con el equipo de soporte en\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Más información sobre el error:

\n" +"
%(details)s
\n" +" " + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Tu proyecto no se ha exportado correctamente.\n" +"\n" +"Los %(product_name)s administradores del sistema han sido informados.\n" +"\n" +"Por favor, inténtalo de nuevo o contacta con el equipo de soporte en " +"%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Volcado de datos de proyecto importado

\n" +"

Hola %(user)s,

\n" +"

El volcado de datos de tu proyecto se ha importado con éxito.

\n" +" Ir a %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(user)s,\n" +"\n" +"El volcado de datos de tu proyecto se ha importado con éxito.\n" +"\n" +"Puedes ver el proyecto %(project)s aquí:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Tu proyecto ha sido importado" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" no se encuentra en este proyecto" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Contenido inválido. Debe ser {\"clave\": \"valor\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "Contiene campos personalizados inválidos." + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "Nombre duplicado" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" +"Una epopeya tiene una historia relacionada de un proyecto externo " +"(%(project)s) y no se puede importar" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Se requiere autenticación" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "nombre" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "URL del icono" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "descripción" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Siguiente URL" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "aplicación" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Token inválido" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "nombre completo" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "dirección de email" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "comentario" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "fecha de creación" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Comentarios

\n" +"

Taiga ha recibido comentarios de %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Comentario

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Información extra" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- De: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comentario:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Información extra:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Feedback de %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "El payload no es un json válido" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "El proyecto no existe" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Firma errónea" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" +"[{user_name}]({user_url} \"Ver el perfil {platform} de {user_name}\") dice " +"en [{platform}#{number}]({comment_url} \"Ir al comentario\"):\n" +"\n" +"\"{comment_message}\"" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"Comentario desde {platform}:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Información de comentario de Issue inválida" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Problema creado por [{user_name}]({user_url} \"Vea el {user_name}'s " +"{platform} perfil\") de [{platform}#{number}]({url} \"Ir al problema\")." + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Incidencia creada desde {platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Problema modificado por [{user_name}]({user_url} \"Ver el perfil {platform} " +"de {user_name}\") de [{platform}#{number}]({url} \"Ir al problema\")." + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "Problema modificado de {platform}." + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Problema cerrado por [{user_name}]({user_url} \"Ver el perfil de {platform} " +"{user_name}\") de [{platform}#{number}]({url} \"Ir al problema\")." + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "Problema cerrado desde {platform}." + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Problema reabierto por [{user_name}]({user_url} \"Ver el perfil {platform} " +"de {user_name}\") desde [{platform}#{number}]({url} \"Ir al problema\")." + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "Problema reabierto desde {platform}." + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Información inválida de Issue" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "usuario desconocido" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} cambió el estado desde un [commit en {platform}]({commit_url} " +"\"Vel el commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Estado: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"Cambiado el estado desde un commit de {platform}.\n" +"\n" +" - Estado: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"Esta {type_name} ha sido mencionada por {user_text} en el [commit de " +"{platform}]({commit_url} \"Ver commit '{commit_id} - " +"{commit_short_message}'\") \"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" +"Esta pertición ha sido mencionada en el commit de {platform} " +"\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "El elemento referenciado no existe" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "El estado no existe" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "El parámetro project es necesario" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Solicitud de API de Asana no válida" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "No se pudo realizar la solicitud a la API de Asana" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "El parámetro code es necesario" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Error importando el proyecto de Asana" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Datos de autenticación inválidos" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "El servicio de terceros está fallando" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "Error importando el proyecto de GitHub" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "El parámetro url es necesario" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" +"\n" +" Se ha producido un error; probablemente debido a una versión " +"de Jira no soportada.\n" +" Taiga no es compatible con versiones de Jira a partir de la " +"8.6." + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "project_type {} inválido" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "Configuración de servidor Jira inválida." + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "El token de atuenticación es inválido o ha expirado" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Error importando el proyecto deJira" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "Error inportando el proyecto de PivotalTracker" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Proyecto de Asana importado

\n" +"

Hola %(user)s,

\n" +"

Tu proyecto de Asana se ha importado correctamente.

\n" +" Ir a %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(user)s,\n" +"\n" +"Su proyecto de Asana se ha importado correctamente.\n" +"\n" +"Puedes ver el proyecto %(project)s aquí:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Tu proyecto de Asana se ha importado" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Proyecto de GitHub importado

\n" +"

Hola %(user)s,

\n" +"

Tu proyecto de GitHub se ha importado correctamente.

\n" +" Ir a %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(user)s,\n" +"\n" +"Su proyecto GitHub ha sido importado correctamente.\n" +"\n" +"Puedes ver el proyecto %(project)s aquí:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] Tu proyecto de GitHub se ha importado" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Hola %(user)s,

\n" +"

Tu proyecto no se ha importado correctamente.

\n" +"

El %(product_name)s se ha informado a los administradores del sistema." +"
Por favor, inténtelo de nuevo o póngase en contacto con el equipo de " +"soporte en\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Proyecto de Jira importado

\n" +"

Hola %(user)s,

\n" +"

Tu proyecto de Jira se ha importado correctamente.

\n" +" Ir a %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(user)s,\n" +"\n" +"Su proyecto de Jira se ha importado correctamente.\n" +"\n" +"Puedes ver el proyecto %(project)s aquí:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Tu proyecto de Jira se ha importado" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Proyecto de Trello importado

\n" +"

Hola %(user)s,

\n" +"

Tu proyecto de Trello se ha importado correctamente.

\n" +" Ir a %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(user)s,\n" +"\n" +"Su proyecto de Trello se ha importado correctamente.\n" +"\n" +"Puedes ver el proyecto %(project)s aquí:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Tu proyecto de Trello se ha importado" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "Petición inválida: %(text)s en %(url)s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "No autorizado: %(text)s en %(url)s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "Recurso no disponible: %(text)s en %(url)s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "Error importando proyecto de Trello" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Ver proyecto" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Ver sprints" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Ver epic" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Ver historias de usuarios" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Ver tareas" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Ver peticiones" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Ver páginas del wiki" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Ver enlaces del wiki" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Añadir sprint" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Modificar sprint" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Borrar sprint" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Añadir epic" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Modificar epic" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "Comentar epic" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Borrar epic" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Ver historia de usuario" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Agregar historia de usuario" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Modificar historia de usuario" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "Comentar historia de usuario" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Borrar historia de usuario" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Agregar tarea" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Modificar tarea" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Comentar tarea" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Borrar tarea" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Añadir petición" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Modificar petición" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "Comentar en petición" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Borrar petición" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Agregar pagina wiki" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Modificar pagina wiki" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Comentar en página wiki" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Borrar pagina wiki" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Agregar enlace wiki" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Modificar enlace wiki" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Borrar enlace wiki" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Modificar proyecto" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Eliminar proyecto" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Agregar miembro" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Eliminar miembro" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Administrar valores de proyecto" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Administrar roles" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "Privacidad" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "Módulos" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Valores por defecto" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "Actividad" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "Seguidores" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "Dueño" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Hacer público" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} hecho público con éxito." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Haz privado" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} se hizo privado satisfactoriamente." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Argumentos incompletos" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Formato de imagen no válido" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "Nombre de plantilla inválido" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "Descripción de plantilla inválida" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "id de usuario inválido" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "El usuario no existe" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "El usuario debe ser ya miembro del proyecto" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "El carril predeterminado no se puede eliminar." + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" +"No puedes borrar la fecha de vencimiento por defecto de una historia de " +"usuario" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "El proyecto ya tiene fechas de vencimiento" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "No puedes borrar la fecha de vencimiento por defecto de una tarea" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "El proyecto ya tiene fechas de vencimiento para tareas" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "No puedes borrar la fecha de vencimiento por defecto de una petición" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "El proyecto ya tiene fechas de vencimiento para peticiones" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" +"Debes verificar tu dirección de correo electrónico para poder invitar " +"miembros a un proyecto" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" +"Este usuario no puede ser eliminado de los siguientes proyectos porque los " +"dejaría sin administradores activos: {}." + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "Correo electrónico obligatorio" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"El proyecto debe tener un dueño y al menos uno de los usuarios debe ser un " +"administrador activo" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "No tienes permisos para verlo." + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "La actualización parcial no está soportada" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "El 'Object id' de la petición no existe" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "El ID de proyecto no coincide entre el adjunto y un proyecto" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "Proyecto" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "típo de contenido" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "id de objeto" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "fecha modificada" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "archivo adjunto" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "está desactualizado" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "desde comentario" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "orden" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" +"Identificador del archivo adjunto no válido para mover después. El archivo " +"adjunto debe pertenecer al mismo elemento (épica, historia de usuario, " +"tarea, problema o página wiki)." + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" +"Identificadores de los archivos adjuntos no válidos. Todos los archivos " +"adjuntos deben pertenecer al mismo elemento (epopeya, historia de usuario, " +"tarea, problema o página wiki)." + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "Whereby.com" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Personalizado" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "El proyecto esta bloqueado por un fallo en el pago" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "El proyecto esta bloqueado por los administradores" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "El proyecto esta bloqueado porque el dueño ha salido" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "Este proyecto esta bloqueado hasta que sea borrado" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +" %(full_name)s ha " +"escrito a %(project_name)s\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +" Está recibiendo este mensaje porque figura como administrador del " +"proyecto denominado %(project_name)s. Si no desea que los miembros de la " +"comunidad de Taiga puedan comunicarse con usted acerca del proyecto, actualice la configuración de su proyecto para evitar estas solicitudes. Las comunicaciones regulares entre los " +"miembros del proyecto no se verán afectadas.\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s ha escrito a %(project_name)s\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +"Estás recibiendo este mensaje porque perteneces al grupo de administradores " +"del proyecto %(project_name)s. Si no quieres que nadie más de la comunidad " +"de Taiga pueda contactar con tu proyecto, actualiza la configuración de tu " +"proyecto en el panel de administración en %(project_settings_url)s " +"deshabilitando esta funcionalidad. El resto de comunicaciones entre los " +"miembros del proyecto no se verán afectadas por este cambio.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s ha enviado un mensaje al proyecto %(project_name)s\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Texto" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Texto multilínea" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "Texto enriquecido" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Fecha" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "Desplegable" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "Casilla de verificación" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "Número" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "tipo" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "valores" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "epopeya" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "historia de usuario" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "tarea" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "petición" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Ya existe uno con el mismo nombre." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "fecha de vencimiento" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "motivo para fijar fecha de vencimiento" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "No tienes permisos para establecer este estado a esta epopeya." + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "estado" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "Orden de epics" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "asunto" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "color" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "asignado a" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "requerido por el cliente" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "requerido por el equipo" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "historias de usuario" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "referencia externa" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "No existe ninguna épica con ese id" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "Comentario requerido" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "comentarios borrados no pueden ser editados" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "El comentario ya ha sido borrado." + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "El comentario no se borro." + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Cambio" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Crear" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Borrar" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "pntos del rol %(role)s" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "de" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "a" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Nuevo adjunto añadido" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Adjunto actualizado" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "obsoleto" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "no obsoleto" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Adjunto borrado" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "añadido" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "borrado" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "No asignado" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "Sin fijar" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-borrado-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "a:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "de:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Añadido" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Cambiado" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Borrado" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "añadido:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "borrado:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "De:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "A:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "contenido" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "nota de bloqueo" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "sprint" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "No tienes permisos para asignar un sprint a esta petición." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "No tienes permisos para asignar un estado a esta petición." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "No tienes permisos para establecer la gravedad de esta petición." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "No tienes permiso para establecer la prioridad de esta petición." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "No tienes permiso para establecer el tipo de esta petición." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "gravedad" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "prioridad" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "sprint" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "fecha de finalización" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "El milestone no es válido para este proyecto" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "Todas las peticiones deben ser del mismo proyecto" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Like" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Likes" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "slug" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "fecha estimada de comienzo" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "fecha estimada de finalización" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "está cerrada" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "disponibilidad" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" +"La fecha de inicio estimada debe ser previa a la fecha de finalización " +"estimada." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "No hay milestones con este id" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "Todas las historias de usuario deben ser del mismo proyecto" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "está bloqueada" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "el parametro ref es necesario" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "el parámetro project o project__slug es necesario" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "Requiere el parámetro de consulta 'moveTo'" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" +"No se puede configurar el carril en Ninguno si hay carriles disponibles" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "el parámetro '{param}' es obligatório" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "el parámetro 'project' es obligatório" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "El usuario debe ser miembro del proyecto." + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "email" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "creado el" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "token" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "texto extra de la invitación" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "orden del usuario" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "El usuario ya es miembro del proyecto" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "valor por defecto del epic" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "estado de historia por defecto" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "puntos por defecto" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "estado de tarea por defecto" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "prioridad por defecto" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "gravedad por defecto" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "estado de petición por defecto" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "tipo de petición por defecto" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "carril predeterminado" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "logo" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "miembros" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "total de sprints" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "puntos de historia totales" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "activar contacto" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "Paneles épicos activos" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "panel de backlog activado" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "panel de kanban activado" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "panel de wiki activo" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "panel de peticiones activo" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "sistema de videoconferencia" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "datos extra de videoconferencia" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "creación de plantilla" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "privado" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "permisos de anónimo" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "permisos de usuario" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "es destacado" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "está buscando a gente" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "notas sobre la búsqueda de gente" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "token de transferencia de proyecto" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "código bloqueado" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "fecha y hora de actualización" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "recuento" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "fans la última semana" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "fans el último mes" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "fans el último año" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "actividad la última semana" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "actividad el último mes" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "actividad el último áño" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "configuración de modulos" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "archivado" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "limite del trabajo en progreso" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "valor" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "predeterminado" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "días para vencimiento" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "estado de la historia del usuario" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "rol por defecto para el propietario" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "opciones por defecto" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "Estados del epic" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "estatuas de historias" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "nuestras fechas de vencimiento" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "puntos" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "estatus de tareas" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "fechas de vencimiento de la tarea" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "estados de petición" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "tipos de petición" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "emitir fechas de vencimiento" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "prioridades" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "gravedades" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "roles" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "atributos personalizados de épicas" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "atributos personalizados de histórias" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "atributos personalizados de tareas" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "atributos personalizados de peticiones" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Involucrado" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Todas" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Ninguna" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "Asignado" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "Mencionado" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "Añadido como observador" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "Añadido como miembro" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "Comentar" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "Mencionado en un comentario" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "fecha y hora de creación" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "entradas del histórico" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "usuarios notificados" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Observado" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" +"Ya existe una política de notificación para este usuario en el proyecto." + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Valor inválido para el nivel de notificación" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"
See epic\n" +" " +msgstr "" +"\n" +"

Épica actualizada

\n" +"

Hola %(user)s,
%(changer)s ha actualizado una épica en " +"%(project)s

\n" +"

Épica #%(ref)s %(subject)s

\n" +" Ver épica\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"Épica actualizada\n" +"Hola %(user)s, %(changer)s ha actualizado una épica en %(project)s\n" +"Ver épica #%(ref)s %(subject)s en %(url)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Actualizada la épica #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +"
See epic\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nueva épica creada

\n" +"

Hola %(user)s,
%(changer)s ha creado una nueva épica en " +"%(project)s

\n" +"

Épica #%(ref)s %(subject)s

\n" +" Ver épica\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nueva épica creada\n" +"Hola %(user)s, %(changer)s ha creado una nueva épica en %(project)s\n" +"Ver épica #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creada la épica #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Épica borrada

\n" +"

Hola %(user)s,
%(changer)s ha borrado una épica en %(project)s\n" +"

Épica #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Épica borrada\n" +"Hola %(user)s, %(changer)s ha borrado una épica en %(project)s\n" +"Épica #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Borrada la épica #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" +"\n" +"

Petición actualizada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha actualizado una petición en:

\n" +"

#%(ref)s %(subject)s

\n" +" Ver la petición\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" +"\n" +"Petición actualizada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha actualizado una petición en:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Ver la petición %(url)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Actualizada la petición #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nueva petición creada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha creada una nueva petición:

\n" +"

#%(ref)s %(subject)s

\n" +" Ver la petición\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nueva petición creada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha creada una nueva petición:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Ver la petición en %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creada la petición #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Petición borrada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha borrado una petición:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Petición borrada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha borrado una petición:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Borrada la petición #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" +"\n" +"

Sprint actualizado en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha actualizado un sprint:

\n" +"

%(name)s

\n" +" Ver " +"el sprint\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" +"\n" +"Sprint actualizado en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha actualizado un sprint:\n" +"\n" +"%(name)s\n" +"\n" +"Ver el sprint et %(url)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Actualizado el sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nuevo sprint creado en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha creado un nuevo sprint

\n" +"

%(name)s

\n" +" Ver " +"el sprint\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nuevo sprint creado en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha creado un nuevo sprint:\n" +"\n" +"%(name)s\n" +"\n" +"Ver el sprint en %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creado el sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Sprint borrado en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha borrado un sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Sprint eliminado en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha eliminado un sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Borrado el Sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" +"\n" +"

Tarea actualizada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha actualizado una tarea:

\n" +"

#%(ref)s %(subject)s

\n" +" Ver la tarea\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" +"\n" +"Tarea actualizada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha actualizado una tarea en:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Ver la tarea en %(url)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Actualizada la tarea #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nueva tarea creada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha creada una nueva tarea:

\n" +"

#%(ref)s %(subject)s

\n" +" Ver la tarea\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nueva tarea creada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha creada una nueva tarea:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Ver la tarea en %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creada la tarea #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Tarea borrada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha borrado una tarea:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Tarea borrada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha borrado una tarea:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Borrada la tarea #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" +"\n" +"

Historia de Usuario actualizada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha actualizado una historia de " +"usuario:

\n" +"

#%(ref)s %(subject)s

\n" +" Ver la historia de usuario\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" +"\n" +"Historia de usuario actualizada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha actualizado una historia de usuario:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Ver la historia de usuario en %(url)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Actualizada la historia #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nueva historia de usuario creada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha creada una nueva historia de " +"usuario:

\n" +"

#%(ref)s %(subject)s

\n" +" Ver la historia de usuario\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nueva historia de usuario creada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha creada una nueva historia de usuario:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Ver la historia de usuario en %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creada la historia #%(ref)s \"%(subject)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Historia de Usuario borrada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha borrado una historia de usuario:\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Historia de Usuario borrada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha borrado una historia de usuario\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Borrada la historia #%(ref)s \"%(subject)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" +"\n" +"

Página Wiki actualizada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha actualizado una página wiki:

\n" +"

%(page)s

\n" +" Ver la " +"Página Wiki\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" +"\n" +"Página Wiki actualizada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha actualizado una página wiki:\n" +"\n" +"%(page)s\n" +"\n" +"Ver la página wiki en %(url)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Actualizada la página del wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nueva página wiki creada en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha creado una nueva página wiki:

\n" +"

%(page)s

\n" +" Ver la " +"página wiki\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nueva página wiki creada en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha creado una nueva página wiki:\n" +"\n" +"%(page)s\n" +"\n" +"Ver la página wiki en %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creada la página del wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Página wiki borrado en %(project)s

\n" +"

Hola %(user)s,
%(changer)s ha borrado una página wiki:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Página wiki borrado en %(project)s\n" +"\n" +"Hola %(user)s, %(changer)s ha borrado una página wiki:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Borrada la página del wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Los observadores tienen usuarios invalidos" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "La versión debe ser un número entero" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "La versión no es válida" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Las version difiere de la actual" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "versión" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"No puedes abandonar el proyecto si eres el dueño o no existen más " +"administradores" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "El token no pertenece a ninguna invitación válida." + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "El usuario no existe." + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Este usuario ya es miembro del proyecto." + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "Dirección de correo electrónico mal redactada." + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Proyecto sin propietario" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "Ha alcanzado el limite de miembros para proyectos privados" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "Ha alcanzado el limite de miembros para proyectos públicos" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "Ha alcanzado el límite actual de invitaciones pendientes" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "Tarea #%(ref)s" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Sprint futuro" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Final de proyecto" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "token inválido" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "El token ha expirado" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "Timeline" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "Épicas" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "Backlog" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "Peticiones" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "TeamWiki" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "No tienes permisos para acceder a esta sección" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" +"Etiqueta '{value}' inválida. El color no es un valor HEX válido o null." + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"'{value}' no es un tag válido. tiene que ser nombre o '[\"name\", \"hex " +"color/\" | null]'." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" +"'{value}' no es un tag válido. tiene que ser nombre o '[\"name\", \"hex " +"color/\" | null]'." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "tags" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "colores de tags" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Este tag ya existe" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "El color no tiene un código hexadecimal válido." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "El tag no existe" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "No tienes permisos para asignar este sprint a esta tarea." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "No tienes permisos para asignar esta historia a esta tarea." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "No tienes permisos para asignar este estado a esta tarea." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "orden en la historia" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "orden en el taskboard" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "tiene iocaína" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "invalido id de milestone." + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "Estado de la tarea tiene un id que no es válido" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "inválido id de historia de usuario." + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" +"Invalido id de estado de tarea. El estado debe pertenecer al mismo proyecto." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" +"Invalido id de historia de usuario. La historia debe pertenecer al mismo " +"proyecto." + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "Invalido id de milestone. El estado debe pertenecer al mismo proyecto." + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" +"Inválidos ids de tareas. Todas las tareas deben pertenecer al msimo proyecto " +"y, si existe, al mismo estado, historia de usuario y/o milestone." + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "Todas las tareas deben ser del mismo proyecto" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "alguien" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" +"\n" +"

¡Has sido invitado a %(product_name)s!

\n" +"

¡Hola! %(full_name)s te ha enviado una invitación para unirte al proyecto " +"%(project)s en %(product_name)s.
Taiga es una herramienta de " +"gestión de proyectos ágiles de código abierto. No hay ningún costo para que " +"seas un usuario de Taiga.

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

Y ahora unas palabras de la persona
que te ha invitado a " +"unirte a Taiga:

\n" +"

%(extra)s

" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Acepta tu invitación a Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Acepta tu invitación" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" +"\n" +"Usted, o alguien que conoce, lo ha invitado a %(product_name)s\n" +"\n" +"¡Hola! %(full_name)s te ha enviado una invitación para unirte al proyecto " +"llamado %(project)s en %(product_name)s.\n" +"Taiga es una herramienta de gestión de proyectos ágiles de código abierto. " +"No hay ningún costo para que seas un usuario de Taiga.\n" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"Y ahora unas palabras de la persona que te ha invitado a unirte a Taiga:\n" +"\n" +"%(extra)s" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Acepta tu injvitación a Taiga accediendo a este enlace:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Invitación para unirte al proyecto '%(project)s'\n" +"\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Te han añadido a un proyecto

\n" +"

Hola %(full_name)s,
te han añadido al proyecto %(project)s

\n" +" Ir al " +"proyecto\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Te han añadido a un proyecto\n" +"\n" +"Hola %(full_name)s, te han añadido al proyecto %(project)s\n" +"\n" +"Ver el proyecto en %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Añadido al proyecto '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Hola %(old_owner_name)s,

\n" +"

%(new_owner_name)s aceptó su oferta y será el nuevo dueño del " +"proyecto de \"%(project_name)s\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s dice:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

Desde ahora su nuevo estado para este proyecto es \"admin\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"Hola %(old_owner_name)s,\n" +"%(new_owner_name)s ha aceptado tu proposición y será el nuevo dueño de " +"\"%(project_name)s\".\n" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s dice:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"De ahora en adelante, tu rol para este proyecto será de \"admin\".\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] ¡Proposición de transferencia de dueño aceptada!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Hola %(owner_name)s,

\n" +"

%(rejecter_name)s declinó su oferta y no será el nuevo dueño del " +"proyecto de \"%(project_name)s\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s dice:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

Si lo desea, aun puede transferir el dominio del proyecto a otra " +"persona.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "Solicitar transferir a una persona diferente" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"Hola %(owner_name)s,\n" +"%(rejecter_name)s ha declinado tu oferta y no será el dueño del nuevo " +"proyecto de \"%(project_name)s\".\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s dice:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"Si deseas, todavía puedes intentar transferir la propiedad del proyecto a " +"una persona diferente.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "Solicitar transferir a una persona diferente:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] Propiedad de transferencia de proyecto rechazada\n" +"\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Hola %(owner_name)s,

\n" +"

%(requester_name)s ha solicitado convertirse en el dueño del " +"proyecto de \"%(project_name)s\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

Por favor, Haga click en \"Continuar\" Si desea iniciar la " +"transferencia del proyecto desde el panel de administracion.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Continuar" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" +"\n" +"Hola %(owner_name)s,\n" +"%(requester_name)s ha solicitado ser el dueño del proyecto de " +"\"%(project_name)s\".\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"Por favor, vaya a la configuracion del proyecto si desea iniciar la " +"tranferencia del proyecto desde el panel de administracion.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Ir a la configuracion del proyecto:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s] Solicitud de transferencia de dominio del proyecto\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

Hola %(receiver_name)s,

\n" +"

%(owner_name)s, el dueño del proyecto \"%(project_name)s\" desea " +"que usted sea el nuevo dueño del proyecto.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s dice:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

Por favor, Haga click en \"Continuar\" Para aceptar o rechazar " +"esta propuesta.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"Hola %(receiver_name)s,\n" +"%(owner_name)s, el dueño del proyecto \"%(project_name)s\" desea que usted " +"sea el nuevo dueño del proyecto\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s dice:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"Por favor, Vaya al siguiente link para aceptar o rechazar esta propuesta.\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "Aceptar o rechazar la transferencia del dominio del proyecto:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s] Oferta de transferencia de dominio del proyecto\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"La pila de historias de usuario o \"product backlog\" en Scrum es una lista " +"priorizada de funcionalidades, que contiene una breve descripción de todas " +"las funciones y requisitos que debe cumplir el producto. Si utilizamos " +"Scrum, no es necesario al inicio del proyecto dedicar tiempo y esfuerzo para " +"tener todas las funcionalidades definidas y priorizadas, si no más bien " +"definir un producto base y dejar que vaya creciendo con el paso del tiempo y " +"las nuevas necesidades del cliente." + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban es un sistema de información para gestionar armónicamente el proceso " +"de creación del producto garantizando el cumplimiento de los tiempos, " +"detectando los posibles sobrecargas del equipo durante las diferentes fases. " +"En esta aproximación, el proceso, desde su definición hasta la entrega final " +"al cliente, se mostrará a los participantes, siendo los miembros del equipo " +"los encargados de que el trabajo fluya haciendo pull de las tareas sobre las " +"diferentes colas." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Nueva" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Preparada" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "En curso" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Lista para testear" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Hecha" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Archivada" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Cerrada" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Necesita información" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Pospuesta" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Rechazada" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Bug" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Pregunta" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Mejora" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Baja" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normal" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Alta" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Deseada" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Menor" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Importante" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Crítica" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Diseñador" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Front" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Back" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Product Owner" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Stakeholder" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"No tienes permisos para asignar este sprint a esta historia de usuario." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" +"No tienes permisos para asignar este estado a esta historia de usuario." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" +"No tienes permiso para configurar esta pista para esta historia de usuario." + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "Inválido id de rol '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "Inválido id de punto de historia '{points_id}'" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Generada la historia de usuario #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "rol" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "orden en el backlog" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "orden en el sprint" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "orden kanban" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "fecha de finalización" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "usuarios asignados" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "generada desde una petición" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "generada desde una tarea" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "referencia de tarea" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "diagrama de carril" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "No existe ninguna historia de usuario con este id" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"Invalido id de estado de historia de usuario. El estado debe pertenecer al " +"mismo proyecto." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" +"Identificación del carril no válida. El carril debe pertenecer al mismo " +"proyecto." + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"Identificador de la historia del usuario es inválido para moverse después. " +"La historia del usuario debe pertenecer al mismo proyecto e hito." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "No se puede utilizar after y before al mismo tiempo." + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"Identificador de historia del usuario inválido para mover antes. La historia " +"del usuario debe pertenecer al mismo proyecto e hito." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" +"Identificador de historia de usuario no válidos. Todas las historias deben " +"pertenecer al mismo proyecto." + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"El identificador de la historia de usuario que se va a mover no es válido. " +"La historia de usuario debe pertenecer al mismo proyecto, estado y calle." + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"El identificador de la historia de usuario que se va a mover no es válido. " +"La historia de usuario debe pertenecer al mismo proyecto, estado y calle." + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "No existe ningún proyecto con este id" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "El usuario aún existe en el proyecto" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "Operación inválida" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Rol inválido para el proyecto" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "El usuario debe ser un contacto válido" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "El dueño del proyecto debe ser administrador" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" +"Por lo menos un usuario debe ser administrador activo para este proyecto" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" +"'role ids' inválidos. Todos los roles deben pertenecer al mismo proyecto." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Opciones por defecto" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Estados de historia de usuario" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Puntos" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Estado de tareas" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Estados de peticion" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Tipos de petición" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Prioridades" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Gravedades" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Roles" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Votos" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Voto" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "el parámetro 'content' es obligatório" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "el parámetro 'project_id' es obligatório" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "última modificación por" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "identificador de la instancia" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Comprueba la API de histórico para obtener el diff exacto" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Miembro del proyecto" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Miembros del proyecto" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "Id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Dueño del proyecto" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Dueños del proyecto" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "Asociaciones" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "DATOS PERSONALES" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "INFORMACIÓN ADICIONAL" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "PERMISOS" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "FECHAS IMPORTANTES" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "RESTRICCIONES A LA PROPIEDAD DEL PROYECTO" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "ESTADÍSTICAS SOBRE LA PROPIEDAD DE LOS PROYECTOS" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "Proyectos sin dueño" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "Asociaciones privadas sin dueño" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "Proyectos públicos propios" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "Afiliaciones públicas propias" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Email duplicado" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "Dirección de correo no válido" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Nombre de usuario o email no válidos" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "¡Correo enviado con éxito!" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "La contraseña actual es obligatoria." + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "La nueva contraseña es obligatoria" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "Longitud de contraseña no válida se necesitan al menos 6 caracteres" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Contraseña actual inválida" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Invalido, ¿estás seguro de que el token es correcto y no se ha usado antes?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Inválido, ¿estás seguro de que el token es correcto?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "Correo electrónico ya verificado" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "No se ha podido verificar este correo electrónico" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "¡Correo enviado con éxito!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "es superusuario" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Otorga todos los permisos a este usuario sin necesidad de hacerlo " +"explicitamente." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "nombre de usuario" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "Obligatorio. 30 caracteres o menos. Letras, números y /./-/_" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Introduce un nombre de usuario válido" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "activo" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Denota a los usuarios activos. Desmárcalo para dar de baja/borrar a un " +"usuario." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "situación del personal" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" +"Indica si el usuario puede iniciar sesión en este sitio de administración." + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografía" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "foto" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "fecha de registro" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "fecha cancelada" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "términos aceptados" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "nuevos términos leídos" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "idioma por defecto" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "tema por defecto" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "zona horaria por defecto" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "añade color a los tags" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "token de email" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "nueva dirección de email" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "numero maximo de proyectos privados asignados" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "numero maximo de proyectos publicos asignados" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" +"número máximo de afiliaciones de los diferentes usuarios para todos los " +"proyectos privados propios" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" +"número máximo de afiliaciones de los distintos usuarios para todos los " +"proyectos públicos propios" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "permisos" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Nombre de usuario o contraseña inválidos." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Cambia tu correo electrónico

\n" +"

Hola %(full_name)s,
por favor, confirma que este correo " +"electrónico te pertenece

\n" +" Confirmar " +"correo electrónico\n" +"

Puedes ignorar este mensaje si no lo has solicitado.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(full_name)s, por favor confirma tu correo electrónico\n" +"\n" +"%(url)s\n" +"\n" +"Puede ignorar este mensaje si no lo solicitó.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Cambiar email" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +"
Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Recuperar tu contraseña

\n" +"

Hola %(full_name)s,
has solicitado la recuperación de tu " +"contraseña

\n" +" Recuperar tu contraseña\n" +"

Ignora este mensaje si no lo has solicita.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(full_name)s, nos has pedido que recuperemos tu contraseña\n" +"\n" +"%(url)s\n" +"\n" +"Ignora este mensaje si no lo has solicitado.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Recuperación de contraseña" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" +"\n" +"

Por favor, confirma tu correo electrónico

\n" +" Confirmar correo electrónico\n" +" " + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" +"\n" +" \n" +"

Gracias por registrarte en %(product_name)s

\n" +"

Nos encanta que te hayas unido a nuestra creciente comunidad " +"de profesionales que están revolucionando su forma de trabajar, y esperamos " +"que lo disfrutes.

\n" +"

¿Tienes preguntas? Contacta con nosotros si necesitas ayuda, " +"estamos en %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +"Puedes borrar tu cuenta de usuario desde aquí" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" +"\n" +"Gracias por registrarte en %(product_name)s\n" +"\n" +"Nos encanta que te hayas unido a nuestra creciente comunidad de " +"profesionales que están revolucionando su forma de trabajar, y esperamos que " +"lo disfrutes.\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" +"\n" +"Por favor, confirma tu dirección de correo electrónico: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" +"\n" +"¿Tienes preguntas? Contacta con nosotros si necesitas ayuda, estamos en " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Puedes eliminar tu cuenta accediendo a este link: %(url)s\n" +"\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "¡Te hemos Taigaizado!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Confirma tu correo electrónico

\n" +"

Hola %(full_name)s,
por favor verifica tu correo electrónico\n" +" Verificar " +"correo electrónico\n" +"

Puedes ignorar este mensaje si no lo has solicitado.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Hola %(full_name)s, por favor verifica tu correo electrónico \n" +"\n" +"%(url)s\n" +"\n" +"Puedes ignorar este correo si no lo has solicitado.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "[Taiga] Verificación de correo electrónico" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "no válido" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Nombre de usuario inválido. Prueba con otro." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "Leer nuevos términos tiene que ser cierto '" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "Violación de una restricción de unicidad. La clave '{}' ya existe." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "clave" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "clave secreta" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "código de estado" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "datos de petición" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "cabeceras de la petición" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "datos de respuesta" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "cabeceras de la respuesta" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "duración" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "Dirección IP no permitida" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "" +#~ "Invalido id de milestone. El estado debe pertenecer al mismo proyecto." + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "" +#~ "Inválidos ids de historias de usuario. Todas las historias deben " +#~ "pertenecer al msimo proyecto y, si existe, al mismo estado y milestone." + +#~ msgid "Personal info" +#~ msgstr "Información personal" + +#~ msgid "Permissions" +#~ msgstr "Permisos" + +#~ msgid "Restrictions" +#~ msgstr "Restricciones" + +#~ msgid "Important dates" +#~ msgstr "datos importántes" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/eu/LC_MESSAGES/django.po b/taiga/locale/eu/LC_MESSAGES/django.po new file mode 100644 index 000000000..b2ab5834d --- /dev/null +++ b/taiga/locale/eu/LC_MESSAGES/django.po @@ -0,0 +1,5410 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Alejandro Hermida , 2019 +# Gotzon Egia , 2020 +# Libre Beats , 2020 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2024-07-09 12:49+0000\n" +"Last-Translator: Alexander Gabilondo \n" +"Language-Team: Basque \n" +"Language: eu\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.7-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "saio-hasiera mota baliogabea" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "Izen-emate publikoa desgaituta dago." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" +"Onartu egin behar dituzu zerbitzuaren baldintzak eta pribatutasun politika" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "Izen-emate mota hori ez da baliozkoa" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "Baimenaren goiburuak zuriuneekin mugatutako bi balio izan behar ditu" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "Emandako tokenak ez du balio inolako token motarentzat" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "Tokenak ez zuen erabiltzaile-identifikazio ezagunik" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "Erabiltzailea ez da aurkitu" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "Erabiltzailea ez dago aktibo" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "'{}' motako algoritmo ezezaguna" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "Kriptografia instalatuta izan behar duzu {} erabiltzeko." + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "Zehaztutako algoritmoa baliogabea da" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "Identifikatzailea ez da baliozkoa edo iraungita dago" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "Erabiltzaile izena ez da baliozkoa" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" +"Nahitaezkoa. 255 karaktere edo gutxiago. Letrak, zenbakiak eta /./-/_ " +"karaktereak'" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Izen osoa ez da baliozkoa" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "Emandako kredentzialekin ez da kontu aktiborik aurkitu" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Erabiltzaile izen hori erabiltzen ari da dagoeneko." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "E-posta hori erabiltzen ari da dagoeneko." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Erabiltzailea erregistratuta dagoeneko." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Akatsa gertatu da erabiltzaile berria sortzerakoan." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "jti" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "erabiltzailea" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "Noiz sortua" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "Iraungitzen da" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "Identifikatzailea ukatzeko zerrenda" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "Ezin da sortu tokena mota edo iraupena zehaztu gabe" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "Tokenak ez du IDrik" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "Tokenak ez du motarik" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "Identifikatzaileak okerreko mota du" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "Tokenak ez du '{}' erreklamaziorik" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "Token '{}' erreklamazioa iraungi da" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Identifikatzailea ez da baliozkoa" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Eremu hau nahitaezkoa da." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Balio hau ez da baliozkoa." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' balioak izan behar du Egia edo Gezurra." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Idatzi baliozko goitizen bat letrak, zenbakiak, behe marrak edo marra " +"arruntak erabilita." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "Hautatu baliozko aukera bat. %(value)s ez dago aukeren artean." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Zure e-posta domeinua ez dago baimenduta" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Idatzi baliozko e-posta helbide bat." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" +"Data-denboren formatua ez da zuzena. Erabili formatu hauetakoren bat: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" +"Data-denboren formatua ez da zuzena. Erabili formatu hauetakoren bat: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "Denboraren formatua ez da zuzena. Erabili formatu hauetakoren bat: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Idatzi zenbaki oso bat." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Ziurtatu balioa dela honen berdina edo txikiagoa: %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Ziurtatu balioa dela honen berdina edo handiagoa: %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" balioak notazio zientifikoa izan behar du." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Idatzi zenbaki bat." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Ziurtatu guztira ez dagoela %s baino zifra gehiago." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Ziurtatu ez dagoela %s baino hamartar gehiago." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" +"Ziurtatu ez dagoela %s baino zifra gehiago dezimalen bereizlearen aurretik." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Ez da fitxategirik bidali. Egiaztatu kodetze mota formularioan." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Ez da fitxategirik bidali." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Aurkeztutako fitxategia hutsik dago." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Ziurtatu fitxategi izenak gehienez %(max)d karaktere dituela (orain " +"%(length)d ditu)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "Edo bidali fitxategi bat edo gaitu «garbitu» laukia, ez biak batera." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Kargatu baliozko irudi bat. Kargatu duzun fitxategia ez da irudi bat edo " +"hondatuta dago." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Osagaia bloketatuta dago" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "Orria ez da «azkena», eta ezin da zenbaki oso bihurtu." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Orri hau (%(page_number)s) baliogabea da: %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Baimen definizio baliogabea." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "'%s' giltza nagusia ez da baliozkoa, ez dago halako objekturik." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Mota ez da zuzena. Espero zen giltza nagusia, jasota %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Ez dago %s=%s duen objekturik." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Baliogabeko esteka - URLrik ez dator bat" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Baliogabeko esteka - URLa ez dator bat" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Baliogabeko esteka, konfigurazio akatsa" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Baliogabeko esteka - ez dago halako objekturik." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Mota ez da zuzena. Espero zen URLa, jasota %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Baliogabeko datuak" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Ez da sarrerarik eman" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Ezin da osagai berri bat sortu, soilik dauden osagaiak eguneratu daitezke." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Espero zen osagaien zerrenda bat." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Ezin da aurkitu" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Ez duzu baimenik" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Zerbitzariaren aplikazio akatsa" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Konexio akatsa." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Gaizki eratutako eskaera." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Egiaztagiriak ez dira zuzenak." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Ez duzu egiaztagiririk aurkeztu." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Ez duzu baimenik ekintza hori egiteko." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "'%s' metodoa ez dago baimenduta." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Ezin da osatu eskaeraren Onartu goiburua" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Eskaeraren '%s' ikus-entzuneko mota ez da onartzen." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Eskaera itota gelditu da." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Expected available in %d second%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Ustekabeko akatsa" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Ezin da aurkitu." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Metodo hori ez da onartzen amaiera puntu honetan." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Argudioak ez dira zuzenak." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Datuen balioespenean akatsa gertatu da" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Integritate akatsa argudioa okerrak edo baliogabeak direlako" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Aurrebaldintza akatsa" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Ez dago lekurik proiektu gehiagorako." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s ez da zerrenda bat" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Akatsa gertatu da parametro motak iragazterakoan." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "«proiektua» balio oso bat izan behar da." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Taigaratuta gelditu zara" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

Ongi etorri " +"%(product_name)s-ra, kode irekiko proiektuak kudeatzeko tresna arina

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" Konfiguratu edo deuseztatu e-" +"posta jakinarazpenak:\n" +"  • \n" +" Taiga Laguntza\n" +"  • \n" +" Jarri harrremanetan " +"gurekin:\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Jarraitu gaitzazu Twitterren" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Hartu kodea GitHuben" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Eguneratzeak" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Eguneratzeak" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +"Iruzkina: %(comment)s" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Zerbitzaria atzitzerakoan akatsa egrtatu da" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "IP helbidearen akatsa" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "Eginkizuna sortu da" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "Eginkizuna aldatu da" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "Eginkizuna ezabatu da" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "Eginkizuna #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Ataza sortu da" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Ataza aldatu da" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Ataza ezabatu da" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Ataza #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Gertakaria sortu da" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Gertakaria aldatu da" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Gertakaria ezabatu da" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Gertakaria #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Wiki orria sortu da" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Wiki orria aldatu da" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Wiki orria ezabatu da" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Wiki orria: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Urratsa sortu da" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Urratsa aldatu da" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Urratsa ezabatu da" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Urratsa: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Gutxienez rol bat behar da" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Deskarga fitxategia behar da" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Deskarga formatua ez da baliozkoa" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "akatsa proiektuaren datuak inportatzen" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "akatsa rolak inportatzen" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "akatsa lankidetzak inportatzen" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "akatsa proiektuaren ezaugarrien zerrenda inportatzen" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "akatsa proiektuaren ezaugarri lehenetsien balioak inportatzen" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "akatsa ezauagrri pertsonalizatuak inportatzen" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "akatsa urratsak inportatzen" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "akatsa gertakariak inportatzen" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "akatsa eginkizunak inportatzen" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "akatsa mugarriak inportatzen" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "akatsa atazak inportatzen" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "akatsa wiki orriak inportatzen" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "akatsa wiki estekak inportatzen" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "akatsa etiketak inportatzen" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "akatsa jarduerak inportatzen" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "ustekabeko akatsa proiektua inportatzen" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Ezin duzu proiektu pribatu gehiiago izan" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" +"Proiektu pribatuetan dezakezun gehienezko lankide kopurura iritsi da " +"proiektu hau" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Ezin duzu proiektu publiko gehiago izan" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" +"Proiektu publikoetan dezakezun gehienezko lankide kopurura iritsi da " +"proiektu hau" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Akatsa proiektuaren deskarga sortzen" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"Akatsa deskarga kargatzerakoan erabiltzaile honekin: {user_full_name} " +"<{user_email}>:\"ARRAZOIA: -------{reason}XEHETASUNAK:--------{details}" +"AKATSAREN ARRASTOA:------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Akatsa proiektuaren deskarga kargatzen" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Akatsa zure proiektuaren deskarga fitxategia kargatzen" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "-- ez dago xehetasunik --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Proiektuaren deskarga sortu da

\n" +"

Kaixo %(user)s,

\n" +"

%(project)s proiektuaren deskarga ongi sortu da.

\n" +"

Hemen eskuratu dezakezu:

\n" +" Eskuratu deskarga fitxategia\n" +"

Fitxategia ezabatu egingo da data honetan: %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Kaixo %(user)s,\n" +"\n" +"%(project)s proiektuaren deskarga ongi sortu da. Hemen eskuratu dezakezu:\n" +"\n" +"%(url)s\n" +"\n" +"Fitxategia ezabatu egingo da data honetan: %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] proiektauren deskarga ongi sortu da" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Kaixo %(user)s,

\n" +"

Zure %(project)s proiektua ez da behar bezala esportatu.

\n" +"

Taiga sistema-administratzaileei jakinarazi zaie.
Mesedez, " +"saiatu berriro edo jarri harremanetan laguntza-taldearekin helbide honetan.\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Kaixo %(user)s,\n" +"\n" +"%(error_message)s\n" +"Zure %(project)s proiektua ez da behar bezala esportatu.\n" +"\n" +"Taigaren sistema administratzaileak jakinaren gainean jarri ditugu.\n" +"\n" +"Mesedez, saiatu berriro edo jarri harremanetan gure asistentzia taldearekin " +"hemen %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Kaixo%(user)s,

\n" +"

Zure proiektua ez da behar bezala inportatu.

\n" +"

%(product_name)s-ko sistema administratzaileak jakinaren gainean " +"jarri ditugu.
Mesedez, saiatu berriro edo jarri harremanetan gure " +"asistentzia taldearekin hemen\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Akatsaren xehetasunak

\n" +"
%(details)s
\n" +" " + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Kaixo %(user)s, \n" +"\n" +"%(error_message)s \n" +"\n" +"Zure proiektua ez da behar bezala inportatu. \n" +"\n" +"%(product_name)s-ko sistema administratzaileak jakinaren gainean jarri " +"ditugu.\n" +"\n" +"Mesedez, saiatu berriro edo jarri harremanetan gure asistentzia taldearekin " +"hemen %(support_email)s \n" +"\n" +"---\n" +" %(signature)s\n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Inportatutako proiektuko datuak txertatzea

\n" +"

Kaixo%(user)s,

\n" +"

Inportatutako zure proiektuaren datuak behar bezala txertatu " +"dira.

\n" +" Joan %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Kaixo%(user)s,\n" +"\n" +"Inportatutako zure proiektuaren datuak behar bezala txertatu dira.\n" +"\n" +" %(project)s proiektua hemen ikusi dezakezu:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] proiektuaren deskarga ongi inportatua izan da." + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" ez da aurkitu proiektu honetan" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Eduki baliogabea. Izan behar da {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "Eremu pertsonalizatu baliogabeak ditu." + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "Izen bikoiztua" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" +"Epic batek kanpo-proiektu bati (%(project)s) lotutako historia du, eta ezin " +"da inportatu" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Egiaztapena nahitaezkoa da" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "izena" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "Ikonoaren URLa" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "azalpena" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Hurrengo URLa" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "aplikazioa" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Identifikatzailea ez da baliozkoa" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "izen osoa" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "e-posta helbidea" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "iruzkina" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "data sortu da" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Iruzkina

\n" +"

Taigak iruzkinak jaso ditu hemendik: %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Iruzkina

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Informazio gehigarria" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------- Nondik: %(full_name)s <%(email)s>---------- Iruzkina:" +"%(comment)s---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Informazio gehigarria:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Iruzkina hemendik: %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "Payload-a ez da baliozko json-a" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Ez dago halako proiekturik" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Sinadura okerra" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" +"[{user_name}]({user_url} \"Ikusi {user_name}'(r)en {platform} profila\") " +"hemen dioenez [{platform}#{number}]({comment_url} \"Joan komentarioetara\"):" +"\n" +"\n" +"\"{comment_message}\"" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "Iruzkina hemendik: {platform}:> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Gertakariaren iruzkin informazio baliogabea" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ondorengoak sortutako eskaera [{user_name}]({user_url} \"Ikusi " +"{user_name}'(r)en {platform} profila\") hemengoa [{platform}#{number}]({url} " +"\"Joan eskaerara\")." + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Gertakaria sortuta hemendik: {platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ondorengoak moldatutako eskera [{user_name}]({user_url} \"Ikus {user_name}(r)" +"en {platform} profila\") hemengoa [{platform}#{number}]({url} \"Joan " +"eskaerara\")." + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "Hemendik {platform} moldatutako eskaera." + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ondorengoak itxitako eskaera [{user_name}]({user_url} \"Ikus {user_name}(r)" +"en {platform} profila\") hemendik [{platform}#{number}]({url} \"Joan " +"eskaerara\")." + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "Hemendik {platform} itxitako eskaera." + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ondorengoak berriz irekitako eskaera [{user_name}]({user_url} \"Ikus " +"{user_name}(r)en {platform} profila\") hemendik [{platform}#{number}]({url} " +"\"Joan eskaerara\")." + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "Hemendik {platform} berriro irekitako eskaera." + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Gertakariaren informazio baliogabea" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "erabiltzaile ezezaguna" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} erabiltzaileak aldatu du egoera hemendik: [{platform} commit]" +"({commit_url} \"Ikusi konpromisoa '{commit_id} - {commit_short_message}'\") " +"- Egoera: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"Aldatu da egoera [{platform} konpromisotik - Egoera: **{src_status}** → " +"**{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"{type_name} aipatu du {user_text} erabiltzaileak hemen: [{platform} commit]" +"({commit_url} \"Ikusi konpromisoa '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" +"Gertakari hau aipatua izan da {platform} konpromisoan\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Aipatutako osagaia ez dago" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Egoera hori ez dago" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Proiektuaren parametroa ezinbestekoa da" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Asana API eskaera ez da baliozkoa" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "Ezin izan da Asana API eskera egin" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Kode parametroa ezinbestekoa da" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Akatsa Asana proiektua inportatzen" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Egiaztapen datuak ez dira baliozkoak" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "Hirugarrengo baten zerbitzuak huts egin du" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "Akatsa GitHub proiektua inportatzen" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "URL parametroa ezinbestekoa da" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" +"\n" +" Errore bat gertatu da; ziurrenik, Jiraren bertsio jasanezin " +"baten ondorioz.\n" +" Taiga ez da bateragarria 8.6 baino berriagoak diren Jiraren " +"bertsioekin." + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "project_type {} baliogabea" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "Jira zerbitzariaren konfigurazioa ez da baliozkoa" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "Egiaztapen identifikatzailea baliogabea edo okerra" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Akatsa Jira proiektua inportatzen" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "Akatsa PivotalTracker proiektua inportatzen" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Asana proiektua inportatu da

\n" +"

Kaixo%(user)s,

\n" +"

Zure Asanako proiektua behar bezala inportatu da.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Kaixo%(user)s,\n" +"\n" +"Zure Asanako proiektua behar bezala inportatu da.\n" +"\n" +" %(project)s proiektua hemen ikusi dezakezu:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Asana proiektua ongi inportatua izan da." + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

GitHub proiektua inportatu da

\n" +"

Kaixo%(user)s,

\n" +"

Zure GitHub proiektua behar bezala inportatu da.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Kaixo%(user)s,\n" +"\n" +"Zure GitHub proiektua behar bezala inportatu da.\n" +"\n" +" %(project)s proiektua hemen ikusi dezakezu:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] GitHub proiektua ongi inportatua izan da." + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Kaixo %(user)s,

\n" +"

Zure proiektua ez da behar bezala inportatu.

\n" +"

%(product_name)saren sistema-administratzaileei jakinarazi zaie.<" +"br/> Mesedez, saiatu berriro edo jarri harremanetan laguntza-taldearekin " +"helbide honetan.\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Jira proiektua inportatua

\n" +"

Kaixo%(user)s,

\n" +"

Zure Jira proiektua behar bezala inportatu da.

\n" +" Hona joan %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Kaixo %(user)s,\n" +"\n" +"Zure Jira proiektua behar bezala inportatu da.\n" +"\n" +" %(project)s proiektua hemen ikusi dezakezu:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Jira proiektua ongi inportatua izan da." + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Trello proiektua inportatua

\n" +"

Kaixo %(user)s,

\n" +"

Zure Trello proiektua behar bezala inportatu da.

\n" +" Hona joan %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Kaixo %(user)s,\n" +"\n" +"Zure Trello proiektua behar bezala inportatu da.\n" +"\n" +"%(project)s proiektua hemen ikusi dezakezu:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Trello proiektua ongi inportatua izan da." + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "Eskaera baliogabea: hemengo %(url)s hau %(text)s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "Baimen gabea: hemengo %(url)s hau %(text)s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "Baliabide atzitu ezina: hemengo %(url)s hau %(text)s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "Akatsa Trello proiektua inportatzen" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Ikusi proiektua" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Ikusi mailak" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Ikusi mugarriak" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Ikusi eginkizunak" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Ikusi atazak" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Ikusi gertakariak" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Ikusi wiki orriak" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Ikusi wiki estekak" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Gehitu maila" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Aldatu maila" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Ezabatu maila" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "gehitu mugarria" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Aldatu mugarria" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "Iruzkindu mugarria" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Ezabatu mugarria" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Ikusi eginkizuna" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Gehitu eginkizuna" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Aldatu eginkizuna" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "Iruzkindu eginkizuna" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Ezabatu eginkizuna" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Gehitu ataza" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Aldatu ataza" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Iruzkindu ataza" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Ezabatu ataza" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Gehitu gertakaria" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Aldatu gertakaria" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "Iruzkindu gertakaria" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Ezabatu gertakaria" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Gehitu wiki orria" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Aldatu wiki orria" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Iruzkindu wiki orria" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Ezabatu wiki orria" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Gehitu wiki esteka" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Aldatu wiki esteka" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Ezabatu wiki esteka" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Aldatu proiektua" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Ezabatu proiektua" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Gehitu lankidea" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Ezabatu lankidea" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Proiektu administratzailearen balioak" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Administratzailearen rolak" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "Pribatutasuna" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "Moduluak" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Balio lehenetsiak" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "Jarduera" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "Jarraitzaileak" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "jabea" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Publiko bihurtu" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} ongi publiko bihuruta." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Pribatu bihurtu" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} ongi pribatu bihurtuta." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Argudio osatu gabeak" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Irudi formatu baliogabea" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "Txantiloi izen baliogabea" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "Txantiloi deskribapen baliogabea" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "Erabiltzaile identifikatzailea ez da baliozkoa" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "Erabiltzaile hori ez dago" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "Erabiltzaileak dagoeneko proiektuko kide izan behar du" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "Lehenetsitako erraila ezin da ezabatu." + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "Ezin duzu kendu epemuga lehenetsia erabiltzailearen istorio batetik" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "Proiektuak dagoeneko baditu mugaegunak" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "Ezin duzu ezabatu ataza baten lehenetsitako epemuga" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "Proiektuak dagoeneko baditu atazaren epemugak" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "Ezin duzu ezabatu eskaera baten lehenetsitako epemuga" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "Proiektuak dagoeneko baditu eskaerarentzat epemugak" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" +"Proiektu batean kideak gehitzeko, lehenik eta behin zure helbide " +"elektronikoa egiaztatu behar duzu" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" +"Erabiltzaile hau ezin da proiektu hauetatik kendu, administratzaile " +"aktiborik gabe utziko lukeelako: {}." + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "Helbide elektronikoa nahitaezkoa da" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"Proiektu honek gutxienez jabe bat behar du eta erabiltzaile bat " +"administratzaile aktibo gisa" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "Ez duzu hori ikusteko baimenik." + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "Zatikako eguneratzeak ezin dira egin." + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "Eskaeraren ID zenbakia ez existitzen" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "Proiektuaren IDa ez dator bat objektuaren eta proiektuaren artean" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "proiektua" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "eduki mota" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "objektuaren identifikatzailea" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "data aldatu da" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "fitxategia erantsi da" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "baztertuta dago" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "iruzkinetatik" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "hurrenkera" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" +"Mugitu beharreko fitxategi erantsiaren IDa ez da baliozkoa. Erantsitako " +"fitxategiak elementu berekoa izan behar du (eginkizuna, erabiltzailearen-" +"istorioa, ataza, eskaera edo wiki orria)." + +#: taiga/projects/attachments/validators.py:61 +#, fuzzy +#| msgid "" +#| "Invalid task ids. All tasks must belong to the same project and, if it " +#| "exists, to the same status, user story and/or milestone." +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" +"Baliogabeak dira atazen identifikatzaileak. Ataza guztiak proiektu berekoa " +"izan behar dira, eta, halakorik badago, egoera, eginkizun edo maila berekoak." + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "Whereby.com" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Pertsonalizatu" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "Proiektu hau blokeatuta dago ordainketa akats batengatik" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "Proiektu hau blokeatu egin du administratzaile taldeak" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "Proiektu hau blokeatuta dago jabea joan egin delako" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "Proiektu hau blokeatuta dago ezabatzen den bitartean" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +" %(full_name)s " +"idatzi du %(project_name)s\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +" Mezu hau jaso duzu administratzaile gisa zaudelako proiektu honetan: " +"%(project_name)s. Ez baduzu nahi Taiga komunitatea zure proiektuarekin " +"harremanetan egotea, mesedez eguneratu " +"proiektuaren ezarpenak harreman horiek baztertzeko. Proiektu barruko " +"lankideen arteko harremanak ez dira, noski, aldatuko.\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(project_name)s%(full_name)s idatzi du hemen: \n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +"Mezu hau jaso duzu administratzaile gisa zaudelako proiektu honetan: " +"%(project_name)s. Ez baduzu nahi Taiga komunitatea zure proiektuarekin " +"harremanetan egotea, mesedez eguneratu proiektuaren ezarpenak hemen:" +"\"%(project_settings_url)s harreman horiek baztertzeko. Proiektu barruko " +"lankideen arteko harremanak ez dira, noski, aldatuko.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s mezua bidali du proiektu honetara: %(project_name)s\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Testua" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Lerro askoko testua" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "Testu aberastua" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Data" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "URL" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "Goitibeherakoa" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "Kontrol-laukia" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "Zenbakia" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "mota" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "balioak" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "mugarria" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "eginkizuna" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "ataza" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "gertakaria" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Badago beste bat izen horrekin." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "epemuga" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "epemugarako zergatia" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "Ez daukazu baimenik mugarri honi egoera hori ipintzeko." + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "Erref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "egoera" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "mugarrien hurrenkera" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "gaia" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "kolorea" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "nori esleitua?" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "bezeroaren eskaera" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "lantaldea behar du" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "eginkizunak" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "kanpo erreferentzia" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "Ez dago mugarririk identifikatzaile horrekin" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "iruzkina nahitaezkoa da" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "ezabatutako iruzkinak ezin dira editatu" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Iruzkina dagoeneko ezabatuta dago" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Ezabatu gabeko iruzkina" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Aldatu" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Sortu" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Ezabatu" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s rolaren puntuak" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "hemendik:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "hona:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Eranskin bat gehitu da" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Eranskina eguneratu da" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "baztertuta" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "ez baztertuta" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "ezabatutako eranskina" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "gehituta" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "kenduta" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "esleitu gabea" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "ezarri gabe" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-ezabatuta-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "nori:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "Hemendik:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Gehituta" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Aldatua" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Ezabatuta" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "gehituta:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "kenduta:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Hemendik:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Nori:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "edukia" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "blokatutako oharra" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "urratsa" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Ez duzu baimenik gertakari honi urrats hori ipintzeko." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Ez daukazu baimenik gertakari honi egoera hori ipintzeko." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "Ez daukazu baimenik gertakari honi larritasun hori ipintzeko." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "Ez duzu baimenik gertakari honi lehentasun hori ipintzeko." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Ez daukazu baimenik gertakari honi mota hori ipintzeko." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "larritasuna" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "lehentasuna" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "maila" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "amaitze data" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "Mala hori ez da baliozkoa proiektu honetan." + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "Eskaera guztiak proiektu berekoak izan behar dira" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Datsegit" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Gogokoak" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "zurrupa" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "begiz jotako hasiera data" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "begiz jotako amaiera data" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "itxita dago" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "erabilgarri" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "Begiz jotako hasiera izan behar da amaiera baino lehenago." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "Ez dago mailarik identifikatzaile horrekin" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "Eginkizun guztiak proiektu berekoak izan behar dira." + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "blokeatuta dago" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "erreferentzia parametroa ezinbestekoa da" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "proiektua edo proiektu__goitizena ezinbestekoa da" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "\"moveTo\" kontsulta-parametroa beharrezkoa da" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" +"Ezin da fluxu-diagrama ezarri Bat ere ez badago fluxu-diagramak erabilgarri " +"badaude" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' parametroa nahitaezkoa da" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' parametroa nahitaezkoa da" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "Erabiltzailea izan behar da proiektu baten lankide" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "e-posta" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "Hemen sortua:" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "identifikatzailea" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "gonbidapenaren testu gehigarria" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "erabiltzaileen hurrenkera" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "Erabiltzailea proiektuaren lankide da dagoeneko." + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "mugarrien egoera lehenetsia" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "eginkizunen egoera lehenetsia" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "puntu lehenetsiak" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "atazen egoera lehenetsia" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "lehentasun lehenetsia" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "larritasun lehenetsia" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "gertakarien egoera lehenetsia" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "gertakarien mota lehenetsia" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "fluxu-diagrama lehenetsia" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "logoa" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "lankideak" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "mailak guztira" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "eginkizunaren puntuak guztira" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "kontaktu aktiboa" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "mugarrien panel aktiboa" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "atzeratutako lanen panel aktiboa" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "kanban panel aktiboa" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "wiki panel aktiboa" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "gertakarien panel aktiboa" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "bideokonferentzia sistema" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "bideokonferentziaren datu gehigarriak" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "sorkuntza txantiloia" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "pribatua da" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "baimen anonimoak" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "erabiltzaileen baimenak" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "nabarmenduta dago" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "lankideen bila" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "lankideak bilatzeko oharra" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "proiektua eskualdatzeko identifikatzailea" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "blokeatutako kodea" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "data eta ordua eguneratuta" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "kontatu" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "jarraitzaileak azken astean" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "jarraitzaileak azken hilabetean" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "jarraitzaileak azken urtean" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "jarduera azken astean" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "jarduera azken hilabetean" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "jarduera azken urtean" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "moduluen konfigurazioa" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "artxibatuta dago" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "aurrerabidearen muga" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "balioa" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "lehenespenaz" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "egunak epemugarako" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "erabiltzailearen historiaren egoera" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "jabearen rol lehenetsia" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "aukera lehenetsiak" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "mugarriaren egoerak" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "eginkizunen egoerak" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "eginkizunen epemugak" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "puntuak" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "atazen egoerak" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "atazen epemugak" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "gertakarien egoerak" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "gertakari motak" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "gertakarien epemugak" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "lehentasunak" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "larritasunak" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "rolak" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "mugarrien ezaugarri pertsonalizatuak" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "eginkizunen ezaugarri pertsonalizatuak" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "atazen ezaugarri pertsonalizatuak" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "gertakarien ezaugarri pertsonalizatuak" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Inplikatuta" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Guztiak" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Batere ez" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "Esleituta" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "Aipatuta" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "Ikusle gisa gehitu da" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "Kide gisa gehitu da" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "Iruzkindu" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "Iruzkinean aipatua" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "sortua data ordua" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "sarreren historikoa" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "jakinarazi erabiltzaileei" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Begiratuta" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Jakinarazpena badago erabiltzaile eta proiektu honeterako" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Baliogabeko balioa jakinarazpen mailarako" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" +"\n" +"

Epika eguneratuta

\n" +"

Kaixo %(user)s,
%(changer)s -ek epika bat eguneratu du %(project)" +"s proiektuan

\n" +"

Epic #%(ref)s %(subject)s

\n" +" Ikusi epika\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"Mugarria eguneratuta\n" +"Kaixo %(user)s, %(changer)s erabiltzaileak mugarri bat eguneratu du hemen: " +"%(project)s\n" +"Ikusi mugarria#%(ref)s: %(subject)s hemen: %(url)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Mugarria eguneratuta #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Epika berria sortu da

\n" +"

Kaixo %(user)s,
%(changer)s -k epika berria sortu du %(project)" +"s proiektuan

\n" +"

Epic #%(ref)s %(subject)s

\n" +" Ikusi epika\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Epika berria sortu da\n" +"Kaixo %(user)s, %(changer)s-k epika berria sortu du %(project)s proiektuan\n" +"Ikusi epika #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Mugarria sortuta #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Epika ezabatu da

\n" +"

Kaixo %(user)s,
%(changer)s -k epika bat ezabatu du %(project)s " +"proiektuan

\n" +"

Epika #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Epika ezabatu da\n" +"Kaixo %(user)s, %(changer)s -k epika bat ezabatu du %(project)s proiektuan\n" +"Epika #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Mugarria ezabatuta#%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" +"\n" +"

Eskaera eguneratu da %(project)s proiektuan

\n" +"

Kaixo %(user)s,
%(changer)s-k eskaera bat eguneratu du:

\n" +"

#%(ref)s %(subject)s

\n" +" Ikusi eskaera\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" +"\n" +"Eskaera eguneratu da %(project)s proiektuan\n" +"\n" +"Kaixo %(user)s, %(changer)s-ek eskaera bat eguneratu du:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Ikusi hemen eskaera %(url)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Gertakaria eguneratuta #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Eskaera berria sortu da %(project)s proiektuan

\n" +"

Kaixo %(user)s,
%(changer)s -k eskaera berria sortu du:

\n" +"

#%(ref)s %(subject)s

\n" +" Ikusi eskaera\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Eskaera berria sortu da %(project)s proiektuan\n" +"\n" +"Kaixo %(user)s, %(changer)s -k eskaera berria sortu du:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Ikusi eskaera hemen %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Gertakaria sortuta #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Eskaera ezabatu da %(project)s proiektuan

\n" +"

Kaixo %(user)s,
%(changer)s -k eskaera bat ezabatu du:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Eskaera ezabatu da %(project)s proiektuan\n" +"\n" +"Kaixo %(user)s, %(changer)s -k ezabatu du eskaera bat:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Gertakaria ezabatuta#%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" +"\n" +"

Sprinta eguneratu da %(project)s proiektuan

\n" +"

Kaixo %(user)s,
%(changer)s -k sprint bat eguneratu du:

\n" +"

%(name)s

\n" +" Ikusi sprinta\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" +"\n" +"Sprinta eguneratu da %(project)s proiektuan\n" +"\n" +"Kaixo %(user)s, %(changer)s-k sprint bat eguneratu du:\n" +"\n" +"%(name)s\n" +"\n" +"Ikusi sprinta hemen %(url)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Eguneratuta urratsa \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +" ....

Sprint berria sortu da %(project)s

\n" +"....

Kaixo %(user)s,
%(changer)s-ek esprint berri bat sortu du

\n" +"....

%(name)s

\n" +"....Ikusi Sprint\n" +"....

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Sortuta urratsa \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Ezabatuta urratsa\"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Ataza eguneratuta #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Sortuta ataza #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Ataza ezabatuta#%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Eginkizuna eguneratuta #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Eginkizuna sortuta #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Eginkizuna ezabatuta#%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Wiki orria eguneratuta\"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Wiki orria sortuta \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Wiki orria ezabatuta \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Begiratzaileen artean baliogabeko erabiltzaileak daude" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "Bertsioa zenbaki osoa izan behar da" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "Bertsio parametroa ez da baliozkoa" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Bertsioa ez dator bat oraingoarekin" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "bertsioa" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"Ezin duzu proiektua utzi jabea baldin bazara edo beste administratzailerik " +"ez badago" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "Identifikatzailea ez dator bat gonbidapen batekin." + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The user doesn't exist" +msgid "User does not exist." +msgstr "Erabiltzaile hori ez dago" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Erabiltzaile hau proiektuaren lankide da dagoeneko." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "e-posta helbide berria" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Jaberik jabeko proiektua" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" +"Proiektu pribatuetan izan daitekeen gehienezko lankide kopurura iritsi zara" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" +"Proiektu publikoetan izan daitekeen gehienezko lankide kopurura iritsi zara" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "Baieztatu gabeko lankideen gehienezko kopurura iritsi zara" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Etorkizuneko urratsak" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Proiektuaren amaiera" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Identifikatzailea ez da baliozkoa" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Identifikatzailea iraungi egin da" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "Eginkizunak" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "'{value}'. etiketa baliogabea. Kolorea ez da HEX kolore baliozkoa" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"'{value}'. etiketa baliogabea. Izan behar da '[\"name\", \"hex color/\" | " +"null]' izena edo parea." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "'{value}'. etiketa baliogabea. Etiketaren izena izan behar da." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "etiketak" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "etiketa kolorea" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Etiketa hori badago lehendik" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "Kolorea ez da HEX kolore baliozkoa." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "Ez dago halako etiketarik." + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "Ez duzu baimenik atazari urrats hori ipintzeko." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "Ez duzu baimenik atazari eginkizun hori ipintzeko." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "Ez duzu baimenik atazari egoera hori ipintzeko." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "eginkizunen hurrenkera" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "ataza arbelen hurrenkera" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "pozoia da" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "Baliogabea da mailaren identifikatzailea" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "Baliogabea da ataza egoeraren identifikatzailea" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "Baliogabea da eginkizunaren identifikatzailea." + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" +"Baliogabea da ataza egoeraren identifikatzailea. Egoera proiektu berekoa " +"izan behar da." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" +"Baliogabea da eginkizunaren identifikatzailea. Eginkizuna proiektu berekoa " +"izan behar da." + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" +"Baliogabea da mailaren identifikatzailea. Maila proiektu berekoa izan behar " +"da." + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" +"Baliogabeak dira atazen identifikatzaileak. Ataza guztiak proiektu berekoa " +"izan behar dira, eta, halakorik badago, egoera, eginkizun edo maila berekoak." + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "baten bat" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

Eta orain hitz labur batzuk, zu gonbidatzeko burutazioa
" +"izan duen lankidearen aldetik

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Onartu Taigarako gonbita" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Onartu gonbita" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"Eta orain hitz labur batzuk, zu gonbidatzeko burutazioa izan duen " +"lankidearen aldetik:\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Onartu Taigara elkartzeko gonbita esteka honetan:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga]Gonbidapena elkartzeko proiektu honetara: '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Gehituta proiektu honetan: '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s erabiltzaileak dio:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

Hemendik aurrera, proiektu honen administratzaile egoera izango " +"duzu.

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s erabiltzaileak dio:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"Hemendik aurrera, proiektu honen administratzaile egoera izango duzu.\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] Proiektuaren jabetza aldaketa onartuta!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s erabiltzaileak dio:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

Nahi baduzu, proiektuaren jabetza besteren bati eskualdatzen saia " +"zaitezke oraindik.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "Beste norbaiti eskualdatzea eskatu" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s erabiltzaileak dio:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"Nahi baduzu, proiektuaren jabetza besteren bati eskualdatzen saia zaitezke " +"oraindik.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "Beste norbaiti eskualdatzea eskatu:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] Ez da onartu proiektuaren jabetza eskualdatzea\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

Mesedez, sakatu «Jarraitu» botoia, nahi baduzu proiektuaren " +"jabetza eskualdatzen hasi administrazio panelean.

" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Jarraitu" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"Mesedez, zoaz proiektuaren ezarpenetara, nahi baduzu proiektuaren jabetza " +"eskualdatzen hasi administrazio panelean.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Zoaz proiektuaren ezarpenetara:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s] Proiektuaren jabetza eskualdatzea eskatu da\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

Kaixo %(receiver_name)s,

\n" +"

%(owner_name)s erabiltzaileak, \"%(project_name)s\" proiektuaren " +"jabe denez, nahi luke zu izatea proiektuaren jabe berria.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s erabiltzaileak dio:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

Mesedez sakatu «Jarraitu» botoia, eskaera onartzeko nahiz " +"baztertzeko.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"Kaixo %(receiver_name)s,\n" +"%(owner_name)s erabiltzaileak, \"%(project_name)s\" proiektuaren jabe denez, " +"nahi luke zu izatea proiektuaren jabe berria.\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s erabiltzaileak dio:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"Mesedez, zoaz esteka honetara eskaera onartzeko nahiz baztertzeko.

\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "Onartu edo baztertu proiektuaren jabetza eskualdatzea:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s] Proiektuaren jabetza aldatzeko eskaintza\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Scrum-en, egin beharreko lana erakusten da lehentasunezko zerrenda gisa, " +"tresnak eskaintzen dituen funtzionalitate guztien azalpen labur batez " +"lagunduta. Scrum erabiltzea erabakitzen baduzu, ez da beharrezkoa proiektua " +"hasterakoan eskakizun guztiak aldez aurretik dokumentatzeko ahalegina " +"egitea. Scrum moduan, egin beharreko lana handitzen eta aldatzen joan " +"daiteke, proiektuaren eskakizunak zehazten doazen neurrian." + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban modua oso egokia da lanean jakintza partekatzeko; egin beharrekoak " +"garaiz egiten laguntzen du, lankideak ito gabe. Ikuspegi honetan, ataza bat " +"definitu eta amaitzen den arte, lankide guztiek bistaratu dezakete, egin " +"gabeko lanen zerrenda antolatu baten gisan." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Berria" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Prest" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "Lanean" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Prest testatzeko" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Egina" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Artxibatuta" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Itxita" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Info gehiago behar" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Geroratuta" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Errefusatuta" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Akatsa" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Galdera" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Nabarmentzea" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Apala" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Arrunta" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Handia" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Nahi genuke…" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Txikia" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Garrantzizkoa" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Hil ala bizikoa" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "Erabiltzailea" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Diseinua" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Aurpegia" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Atzealdea" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Jabea" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Partaidea" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "Ez duzu baimenik eginkizunari urrats hori ipintzeko." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "Ez duzu baimenik eginkizunari egoera hori ipintzeko." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "Baliogabea da rol hau: '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "Baliogabeak dira puntu hauek: '{points_id}'" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Eginkizun hau sortzen: #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "rola" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "atzeratutako lanaren hurrenkera" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "urratsen hurrenkera" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "kanban hurrenkera" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "amaitze data" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "esleitutako erabiltzaileak" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "gertakari batetik sortua" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "fluxu-diagrama" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Ez dago eginkizunik identifikatzaile horrekin" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"Baliogabea da eginkizunaren egoeraren identifikatzailea. Egoera proiektu " +"berekoa izan behar da." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"Baliogabea da eginkizunaren identifikatzailea. Eginkizuna proiektu berekoa " +"izan behar da." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"Baliogabea da eginkizunaren identifikatzailea. Eginkizuna proiektu berekoa " +"izan behar da." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Ez dago proiekturik halako identifikatzailea duenik." + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "Erabiltzaile hori badago proiektuan dagoeneko." + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Balioagabea da rol hori proiektu honetan." + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "Erabiltzailea izan behar da baliozko kontaktu bat" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "Proiektuaren jabea izan behar da administratzailea" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" +"Gutxienez erabiltzaile bat izan behar da administratzaile aktiboa proiektu " +"honetan." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" +"Baliogabeak dira rol identifikatzailek. Rol guztiak proiektu berekoak izan " +"behar dira." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Aukera lehenetsiak" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Eginkizunen egoerak" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Puntuak" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Atazen egoerak" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Gertakarien egoerak" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Gertakari motak" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Lehentasunak" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Larritasunak" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Rolak" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Bozkak" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Bozka" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content' parametroa nahitaezkoa da" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project id' parametroa nahitaezkoa da" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "azken aldatzailea" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Egiaztatu API historiala DIFF zehatza aurkitzeko" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Proiektuaren lankidea" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Proiektuaren lankideak" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "identifikatzailea" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Proiektuaren jabea" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Proiektuaren jabeak" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "lankideak" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Proiektuaren amaiera" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Proiektuaren amaiera" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "e-posta bikoiztua" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Erabiltzaile izena edo e-posta ez da baliozkoa" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Ezinbestekoa da oraingo pasahitza" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Ezinbestekoa da pasahitz berria" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Oraingo pasahitza ez da baliozkoa" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Ez da baliozkoa; ziur zaude identifikatzailea zuzena dela eta ez duzula " +"lehenago erabili?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Ez da baliozkoa; ziur zaude identifikatzailea zuzena dela?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "Posta ongi bidali dugu" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "supererabiltzaile egoera" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Adierazten du erabiltzaile honek baimen guztiak dituela, berariaz esleitu " +"behar izan gabe." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "erabiltzaile izena" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" +"Nahitaezkoa. 30 karaktere edo gutxiago. Letrak, zenbakiak eta /./-/_ " +"karaktereak" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Idatzi baliozko erabiltzaile izen bat." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "aktiboa" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Adierazten du erabiltzaile hau noiz haurtu behar den aktibotzat. Desgaitu " +"hau, kontuak ezabatu ordez." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografia" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "argazkia" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "noiz elkartua" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "noiz elkartua" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "onartutako terminoak" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "termino berriak irakurri" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "hizkuntza lehenetsia" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "gai lehenetsia" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "ordu-zona lehenetsia" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "koloreztatu etiketak" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "e-posta identifikatzailea" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "e-posta helbide berria" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "proiektu pribatuen gehienezko jabetza kopurua" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "proiektu publikoen gehienezko jabetza kopurua" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" +"gehienezko erabiltzaile kopurua jabetzapeko proiektu pribatu bakoitzeko" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" +"gehienezko erabiltzaile kopurua jabetzapeko proiektu publiko bakoitzeko" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "baimenak" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Erabiltzaile izena edo pasahitza ez datoz bat erabiltzaile honekin." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Aldatu e-posta" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga]Berreskuratu pasahitza" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" Zure kontua ezabatu dezakezu hemen klik eginda\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Zure kontua ezabatu dezakezu hemendik: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Taigaratuta gelditu zara!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "baliogabea" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Erabiltzaile izena ez da baliozkoa. Saiatu beste batekin." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "Termino berriak irakurrita markatu behar duzu." + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "Giltza ezin da bikoiztuta egon. '{}' giltza badago lehendik." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "giltza" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "giltza sekretua" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "egoera kodea" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "eskatu datuak" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "eskatu goiburuak" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "erantzun datuak" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "erantzun goiburuak" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "iraupena" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "IP helbidea ez da baliozkoa" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "" +#~ "Baliogabea da mailaren identifikatzailea. Maila proiektu berekoa izan " +#~ "behar da." + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "" +#~ "Baliogabeak dira eginkizunen identifikatzaileak. Eginkizun guztiak " +#~ "proiektu berekoa izan behar dira, eta, halakorik badago, egoera eta maila " +#~ "berekoak." + +#~ msgid "Personal info" +#~ msgstr "Info pertsonala" + +#~ msgid "Permissions" +#~ msgstr "Baimenak" + +#~ msgid "Restrictions" +#~ msgstr "Murrizpenak" + +#~ msgid "Important dates" +#~ msgstr "Data garrantzitsuak" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/fa/LC_MESSAGES/django.po b/taiga/locale/fa/LC_MESSAGES/django.po new file mode 100644 index 000000000..48cc82948 --- /dev/null +++ b/taiga/locale/fa/LC_MESSAGES/django.po @@ -0,0 +1,5070 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Ali Tavallaie , 2019 +# Amirhoshang Hoseinpour Dehkordi , 2018 +# Vahid Dayyani , 2018-2019 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2023-04-04 06:36+0000\n" +"Last-Translator: Amin \n" +"Language-Team: Persian \n" +"Language: fa\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.17-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "نوع لاگین نامعتبر است" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "ثبت‌نام عمومی غیرفعال است." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "شما باید موارد سرویس و سیاست های امنیت ما را قبول کنید." + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "ثبت نام نا معتبر" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "مجوز سرصفحه باید شامل دو مقدار با فاصله محدود باشد" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "توکن داده شده برای هیچ نوع توکن معتبر نیست" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "توکن هیچ شناسه کاربری قابل تشخیص نبود" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "کاربر پیدا نشد" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "کاربر غیر فعال است" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "نوع الگوریتم ناشناخته است \"{}\"" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "برای استفاده از {} باید رمزنگاری را نصب کرده باشید." + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "الگوریتم نامعتبر مشخص شده است" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "توکن نامعتبر است یا باطل شده" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "نام کاربری نامعتبر" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "255 کاراکتر یا کمتر ضروری است. حروف و اعداد و . و - و ـ مجاز است." + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "نام کامل نامعتبر است" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "هیچ حساب فعالی با اعتبار داده شده یافت نشد" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "این نام کاربری قبلاً استفاده شده است." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "این ایمیل قبلاً استفاده شده است." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "کاربر قبلاً ثبت نام کرده است." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "خطا هنگام ایجاد کاربر جدید." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "جیت" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "کاربر" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "ایجاد شده در" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "به اتمام میرسد در" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "توکن در لیست نیست" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "نمی توان توکن بدون نوع یا طول عمر ایجاد کرد" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "توکن هیچ شناسه ای ندارد" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "نوع توکن مشخص نیست" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "توکن نوع اشتباهی دارد" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "توکن ادعای \"{}\" ندارد" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "ادعای توکن {} منقضی شده است" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "توکن رد شده است" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "این فیلد ضروری است." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "مقدار نامعتبر." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' می‌بایست صحیح یا غلط باشد." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"نام کوتاه معتبری که شامل حروف و اعداد و خط فاصله یا آندرلاین باشد، انتخاب " +"کنید." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "انتخاب معتبری انجام دهید. %(value)s یکی از انتخاب‌های معتبر نیست." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "دامین ایمیل مجاز نیست" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "آدرس ایمیل معتبری وارد کنید." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "تاریخ فرمت صحیحی ندارد. یکی از این فرمت‌ها را انتخاب کنید: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "تاریخ و ساعت فرمت صحیحی ندارد. یکی از این فرمت‌ها را انتخاب کنید: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "ساعت فرمت صحیحی ندارد. یکی از این فرمت‌ها را انتخاب کنید: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "یک عدد وارد کنید." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "مطمئن شوید که مقدار کوچک‌تر یا مساوی %(limit_value)s است." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "مطمئن شوید که مقدار بزرگ‌تر یا مساوی %(limit_value)s است." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" می‌بایست عددی اعشاری باشد." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "عددی وارد کنید." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "مطمئن شوید که بیش از %s رقم وجود ندارد." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "مطمئن شوید که بیش از %s اعشار وجود ندارد." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "مطمئن شوید که بیش از %s رقم قبل از اعشار وجود ندارد." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "فایلی ثبت نشده است. اینکدینگ و نوع فرم را بررسی کنید." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "فایلی ثبت نشده است." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "فایل ثبت‌شده خالی است." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"مطمئن شوید که نام فایل حداکثر %(max)d حرف دارد (نام فایل شامل %(length)d حرف " +"است)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "لطفاً فایلی را ثبت کنید یا چک‌باکس را تیک بزنید و نه هر دو." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"عکس معتبری آپلود کنید. فایلی که آپلود شده فایل عکس نیست و یا فایلی خراب است." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "المان مسدودشده" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "صفحه 'آخرین' نیست یا قابلیت تبدیل به یک عدد وجود ندارد." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "صفحه نامعتبر (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "تعریف دسترسی نامعتبر است" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "pk نامعتبر '%s' - این شیء وجود ندارد." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "نوع اشتباه. مقدار pk، %s دریافت شده." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "شیئی با %s=%s وجود ندارد." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "لینک نامعتبر - URL وجود ندارد" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "لینک نامعتبر - URL اشتباه" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "لینک نامعتبر به دلیل خطای پیکربندی" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "لینک نامعتبر - شیء وجود ندارد." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "نوع اشتباه است. عبارت URL لازم است اما %s دریافت شده است." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "داده اشتباه" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "ورودی دریافت نشده است" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "ایجاد آیتم جدید ممکن نیست، تنها آیتم‌های موجود آپدیت می‌شود." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "لیستی از موارد لازم است." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "پیدا نشد" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "دسترسی مجاز نیست" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "خطای اپلیکیشن سرور" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "خطای کانکشن." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "درخواست ناجور." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "اطلاعات ورود اشتباه است." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "اطلاعات ورود وارد نشده است." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "شما دسترسی لازم برای این کار را ندارید." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "روش'%s' مجاز نیست." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "پذیرش هدر درخواست تأیید نشد" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr " نوع مدیا '%s' در درخواست پشتیبانی نمی‌شود." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "کند بودن رسیدگی به درخواست" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "پیش‌بینی می‌شود که در %d ثانیه %s فراهم شود." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "خطای غیرمنتظره" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "پیدا نشد." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "روش در این نقطه پایان پشتیبانی نمی‌شود." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "آرگومان اشتباه." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "خطای تأیید اعتبار داده" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "خطای درستی به دیلیل نامعتبر بودن آرگومان‌ها" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "خطای شرط قبلی" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "فضا برای پروژه‌های بیشتر وجود ندارد." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s یک لیست نیست" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "خطا در نوع پارامترهای فیلتر." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'پروژه' می‌بایست عددی صحیح باشد." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "تایگا" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "تایگایی شده‌اید" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

به %(product_name)s، یک " +"ابزار مدیریت پروژه چابک منبع باز، خوش آمدید

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" پیکربندی اعلان‌های ایمیل یا " +"لغو اشتراک\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" تماس با ما\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "ما را در توییتر دنبال کنید" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "دریافت کد در گیت‌ها" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "آپدیت‌های [تایگا]" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "آپدیت‌ها" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +" دیدگاه‌ها:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" دیدگاه: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "خطای دسترسی به هاست" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "خطای آدرس آی پی" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "گزارش کاربر ساخته شد." + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "گزارش کاربر تغییر کرد." + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "گزارش کاربر حذف شد." + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "US #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "وظیفه ساخته شد." + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "وظیفه تغییر کرد." + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "وظیفه حذف شد." + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "وظیفه #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "موضوع ساخته شد." + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "موضوع تغییر کرد." + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "موضوع حذف شد." + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "موضوع: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "صفحه ویکی ساخته شد." + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "صفحه ویکی تغییر داده شد." + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "صفحه ویکی حذف شد." + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "صفحه ویکی: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "سرعتی ساخته شد." + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "سرعتی تغییر داده شد." + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "سرعتی حذف شد." + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "سرعتی: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "حداقل یک نقش لازم است" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "اطلاعات عیب‌یابی لازم است" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "نوع عیب‌یابی نامعتبر" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "خطا در وارد کردن اطلاعات پروژه" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "خطا در وارد کردن نقش‌ها" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "خطا در وارد کردن عضویت‌ها" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "خطا در وارد کردن لیست خواص پروژه" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "خطا در وارد کردن مقادیر مشخصه پروژه پیش‌فرض" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "خطا در وارد کردن خواص سفارشی" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "خطا در وارد کردن پیشرفت‌ها" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "خطا در وارد کردن موضوعات" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "خطا در وارد کردن استوری‌های کاربری" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "خطا در وارد کردن epic" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "خطا در وارد کردن وظایف" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "خطا در وارد کردن صفحات ویکی" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "خطا در وارد کردن لینک‌های ویکی" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "خطا در وارد کردن برچسب‌ها" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "خطا در وارد کردن تایم‌لاین‌ها" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "خطای غیرمنتظره در وارد کردن پروژه" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "نمی‌توانید پروژه‌های خصوصی بیشتری داشته باشید" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "این پروژه به محدودیت فعلی تعداد اعضای پروژه‌های خصوصی شما رسیده است" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "نمی‌توانید پروژه‌های عمومی بیشتری داشته باشید" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "این پروژه به محدودیت فعلی تعداد اعضای پروژه‌های عمومی شما رسیده است" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "خطا در ایجاد اطلاعات عیب‌یابی پروژه" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"خطا در بارگذاری فایل عیب‌یابی توسط {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"علت:\n" +"-------\n" +"{reason}\n" +"\n" +"جزئیات:\n" +"--------\n" +"{details}\n" +"\n" +"رهگیری خطا:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "خطا در بارگذاری اطلاعات عیب‌یابی پروژه" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "خطا در بارگذاری اطلاعات عیب‌یابی پروژه‌ی شما" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "-- اطلاعاتی در مورد جزئیات موجود نیست --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] فایل عیب‌یابی پروژه‌ی شما ایجاد شد" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[تایگا] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] فایل عیب‌یابی پروژه‌ی شما ایمپورت شده است" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" در این پروژه پیدا نشد" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "محتوای نامعتبر. می‌بایست {\"key\": \"value\",...} باشد" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "احراز هویت لازم است" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "نام" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "لینک آیکون" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "وب" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "توضیحات" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "لینک بعدی" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "اپلیکیشن" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "توکن نامعتبر" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "نام کامل" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "آدرس ایمیل" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "دیدگاه" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "زمان ایجاد" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

بازخورد

\n" +"

تایگا بازخورد را از این کاربر و ایمیل دریافت کرده: %(full_name)s " +"<%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

دیدگاه

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "اطلاعات بیشتر" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- از: %(full_name)s <%(email)s>\n" +"---------\n" +"- دیدگاه:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- اطلاعات بیشتر:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[تایگا] بازخورد از %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "پروژه وجود ندارد" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "امضای دیجیتالی بد" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"فرم نظرات {platform}:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "موضوع دیدگاه نامعتبر است" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "موضوع از {platform} ایجاد شده است." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "اطلاعات موضوع نامعتبر است" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "کاربر ناشناس" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} وضعیت را از [{platform} commit]({commit_url} \"دیدن عمل " +"'{commit_id} - {commit_short_message}'\")\n" +"\n" +" - وضعیت: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"وضعیت تغییر کرده است {platform}.\n" +"\n" +" - وضعیت: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"به این {type_name} توسط {user_text} اشاره شده است، در [{platform} commit]" +"({commit_url} \"دیدن عمل '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "این موضوع در عمل {platform} \"{commit_message}\"مورداشاره بوده است" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "المان اشاره شده موجود نیست" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "وضعیت وجود ندارد" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "پارامتر پروژه لازم است" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "درخواست نامعتبر API Asana" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "خطا در ارسال درخواست به Asana API" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "پارامتر کد لازم است" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "خطا در وارد کردن پروژه‌ی Asana" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "داده‌های تأیید هویت نامعتبر است" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "سرویس جانبی با خطا مواجه شد" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "خطا در وارد کردن پروژه‌ی گیت‌هاب" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "پارامتر URL لازم است" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "نوع پروژه نامعتبر است {}" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "پیکربندی اشتباه سرویس Jira" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "توکن نامعتبر یا منقضی‌شده‌ی احراز هویت" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "خطا در ایمپورت کردن پروژه‌ی Jira" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "خطا در وارد کردن پروژه‌ی PivotalTracker" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] پروژه‌ی Asana شما ایمپورت شد" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] پروژه‌ی گیت‌هاب شما ایمپورت شد" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] پروژه‌ی Jira شما به درستی ایمپورت شد" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] پروژه‌ی ترلوی شما به درستی ایمپورت شد" + +#: taiga/importers/trello/importer.py:57 +#, fuzzy, python-format +#| msgid "Invalid Request: %s at %s" +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "درخواست نامعتبر: %s در %s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, fuzzy, python-format +#| msgid "Unauthorized: %s at %s" +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "غیرمجاز: %s در %s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, fuzzy, python-format +#| msgid "Resource Unavailable: %s at %s" +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "در دسترس نبودن منابع: %s در %s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "خطا در وارد کردن پروژه‌ی ترلو" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "مشاهده کردن پروژه" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "نمایش مراحل پیشرفت" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "نمایش Epic" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "نمایش استوری‌های کاربری" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "نمایش وظایف" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "نمایش موضوعات" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "نمایش صفحات ویکی" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "نمایش لینک‌های ویکی" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "اضافه کردن پیشرفت" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "تصحیح کردن پیشرفت" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "حذف کردن پیشرفت" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "اضافه کردن epic" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "ویرایش epic" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "دیدگاه برای epic" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "حذف epic" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "نمایش استوری‌های کاربری" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "اضافه کردن استوری‌های کاربری" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "تصحیح استوری‌های کاربری" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "دیدگاه برای استوری‌های کاربری" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "حذف کردن استوری‌های کاربری" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "اضافه کردن وظیفه" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "ویرایش وظیفه" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "دیدگاه برای وظیفه" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "حذف کردن وظیفه" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "اضافه کردن موضوع" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "ویرایش موضوع" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "دیدگاه برای موضوع" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "حذف موضوع" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "اضافه کردن صفحه ویکی" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "ویرایش صفحه ویکی" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "دیدگاه برای صفحه ویکی" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "حذف کردن صفحه ویکی" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "اضافه کردن لینک ویکی" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "ویرایش لینک ویکی" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "حذف کردن لینک ویکی" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "ویرایش پروژه" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "حذف پروژه" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "افزودن کاربر" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "حذف کاربر" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "مقادیر پروژه ادمین" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "نقش‌های ادمین" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "ماژول‌ها" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "حذف مقادیر" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "فعالیت" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "علاقه‌مندان" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "مالک" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "عمومی کردن" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} با موفقیت عمومی شد." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "خصوصی کردن" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} با موفقیت خصوصی شد." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "آرگومان‌های ناقص" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "فرمت عکس نامعتبر" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "شناسه‌ی کاربری نامعتبر" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "کاربر وجود ندارد" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "comment is required" +msgid "Email is required" +msgstr "دیدگاه ضروری است" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"پروژه می‌بایست یک مالک داشته باشد و حداقل یکی از اعضا می‌بایست ادمین فعال باشد" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "بروزرسانی‌های جزئی پشتیبانی نمی‌شود." + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "پروژه" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "نوع محتوا" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "شناسه‌ی مورد" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "تاریخ تصحیح" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "فایل پیوست" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "منسوخ شده است" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "از دیدگاه" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "ترتیب" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +#, fuzzy +#| msgid "" +#| "Invalid task ids. All tasks must belong to the same project and, if it " +#| "exists, to the same status, user story and/or milestone." +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" +"شناسه‌های نامعتبر وظیفه. تمام وظایف می‌بایست به همان پروژه تعلق داشته باشند، " +"در صورت وجود به همان وضعیت، استوری کاربری و یا مرحله‌ی پیشرفت متعلق باشند" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "Whereby.com" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "سفارشی" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "خوش‌صحبت" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "این پروژه به دلیل شکست در پرداخت مسدود شده است" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "این پروژه توسط گروه ادمین مسدود شده است" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "این پروژه به دلیل ترک کردن مالک مسدود شده است" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "این پروژه در زمان حذف آن مسدود شده است" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +" %(full_name)s " +"نوشته شده در %(project_name)s\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +" شما این پیام را به این دلیل دریافت می‌کنید که به عنوان ادمین پروژه‌ای " +"با عنوان: %(project_name)s انتخاب شده‌اید. اگر نمی‌خواهید اعضای انجمن‌های تایگا " +"با پروژه‌ی شما تماس بگیرند، لطفاً تنظیمات پروژه‌ی خود را بروز کنید تا " +"چنین تماس‌هایی برقرار نشود. ارتباطات بین اعضای پروژه تغییری نمی‌کند." + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s نوشته شده در %(project_name)s\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +" شما این پیام را به این دلیل دریافت می‌کنید که به عنوان ادمین پروژه‌ای با " +"عنوان: %(project_name)s انتخاب شده‌اید. اگر نمی‌خواهید اعضای انجمن‌های تایگا با " +"پروژه‌ی شما تماس بگیرند، لطفاً تنظیمات پروژه‌ی خود را در " +"%(project_settings_url)s بروز کنید تا چنین تماس‌هایی برقرار نشود. ارتباطات " +"بین اعضای پروژه تغییری نمی‌کند.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s پیامی به پروژه فرستاده است %(project_name)s\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "متن" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "متن چند خطی" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "متن دارای فرمت" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "تاریخ" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "لینک" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "کشویی" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "کادر انتخاب" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "عدد" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "نوع" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "مقادیر" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "epic" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "استوری کاربری" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "وظیفه" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "موضوع" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "موردی با همین نام وجود دارد." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "موعد سر رسید" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "دلیل موعد سر رسید." + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "شما دسترسی لازم برای انتخاب وضعیت این epic را ندارید." + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ریفرنس" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "وضعیت" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "ترتیب epicها" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "موضوع" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "رنگ" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "اختصاص یافته به" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "لازمه‌ی مشتری است" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "لازمه‌ی تیمی است" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "استوری‌های کاربری" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "ریفرنس خارجی" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "epic با این شناسه وجود ندارد" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "دیدگاه ضروری است" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "حذف کردن کامنت‌هایی که قابل ویرایش نیستند" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "دیدگاه قبلاً حذف شده است" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "دیدگاه حذف نشده است" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "تغییر" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "ایجاد" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "حذف" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "امتیازات نقش %(role)s " + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "از" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "به" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "پیوست جدید اضافه شد" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "پیوست آپدیت شد" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "منسوخ‌شده" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "منسوخ‌نشده" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "پیوست حذف شد" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "اضافه شد" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "حذف شد" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "آزاد شد" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "تنظیم نشده" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "- حذف شد-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "به:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "از:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "اضافه شد" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "تغییر کرد" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "حذف شد" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "اضافه شد:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "حذف شد:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "از:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "به:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "محتوا" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "یادداشت مسدود شده" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "پیشروی" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "شما دسترسی لازم برای اضافه کردن این پیشرفت به این موضوع را ندارید." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "شما دسترسی لازم برای تنظیم این وضعیت برای این موضوع را ندارید." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "شما دسترسی لازم برای تنظیم این اهمیت برای این موضوع را ندارید." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "شما دسترسی لازم برای تنظیم این اولویت برای این موضوع را ندارید." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "شما دسترسی لازم برای تنظیم این نوع برای این موضوع را ندارید." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "اهمیت" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "اولویت" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "مرحله پیشرفت" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "تاریخ تکمیل" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "مرحله‌ی پیشرفت برای پروژه معتبر نیست." + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "تمامی مسائل باید از پروژه یکسان باشند" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "لایک" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "لایک‌ها" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "نام کوتاه" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "تاریخ تخمینی شروع" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "تاریخ تخمینی تکمیل" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "بسته شده" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "در دسترس بودن" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "تاریخ تخمینی شروع می‌بایست قبل از تاریخ تخمینی پایان باشد." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "مرحله‌ی پیشروی با این شناسه وجود ندارد" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "تمام استوری‌های کاربری می‌بایست از همان پروژه باشند." + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "مسدود شده است" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "پارامتر ریفرنس لازم است" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "پروژه یا پارامتر نام کوتاه پروژه لازم است" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "پارامتر '{param}' اجباری است" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "پارامتر 'project' اجباری است" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "کاربر می‌بایست یکی از اعضای پروژه باشد" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "ایمیل" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "ایجادشده در تاریخ" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "توکن" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "متن اضافی برای دعوت‌نامه" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "ترتیب کاربران" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "کاربر قبلاً عضوی از پروژه بوده است" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "وضعیت پیش‌فرض epic" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "وضعیت پیش‌فرض استوری‌های کاربری" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "امتیاز پیش‌فرض" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "وضعیت پیش‌فرض وظیفه" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "اولویت پیش‌فرض" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "اهمیت پیش‌فرض" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "وضعیت پیش‌فرض موضوعات" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "نوع پیش‌فرض موضوعات" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "لوگو" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "اعضا" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "مجموع مراحل پیشرفت" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "مجموع امتیازات استوری‌ها" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "مخاطب فعال" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "پنل epicهای فعال" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "پنل لیست امور ناتمام فعال" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "پنل کانبان‌های فعال" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "پنل ویکی‌های فعال" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "پنل موضوعات فعال" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "سیستم ویدیوکنفرانس" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "اطلاعات اضافی ویدیوکنفرانس" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "قالب ایجاد" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "خصوصی است" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "دسترسی‌های ناشناس" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "دسترسی‌های کاربر" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "ویژه است" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "به دنبال عضو است" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "به دنبال یادداشت‌های عمومی است" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "توکن انتقال پروژه" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "کد مسدودشده" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "تاریخ و ساعت آپدیت" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "تعداد" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "علاقه‌مندان در هفته‌ی اخیر" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "علاقه‌مندان در ماه اخیر" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "علاقه‌مندان در سال اخیر" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "فعالیت‌ها در هفته‌ی اخیر" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "فعالیت‌ها در ماه اخیر" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "فعالیت‌ها در سال اخیر" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "پیکربندی ماژول‌ها" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "آرشیو شده است" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "محدودیت کارهای در حال انجام" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "ارزش" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "بصورت عمومی" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "روز تا موعد" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "نقش پیش‌فرض مالک" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "تنظیمات پیش‌فرض" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "وضعیت‌های epic" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "وضعیت‌های استوری‌های کاربری" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "موعد ما" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "امتیاز" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "وضعیت‌های وظایف" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "موعد وظیفه ها" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "وضعیت‌های موضوعات" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "انواع موضوعات" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "موعد موضوعات" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "اولویت‌ها" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "اهمیت‌ها" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "نقش‌ها" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "خواص سفارشی epic" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "خواص سفارشی استوری‌های کاربر" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "خواص سفارشی وظایف" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "خواص سفارشی موضوعات" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "مشترک" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "همه" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "هیچ" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "اختصاص داده شده" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "اشاره شده" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "اضافه‌ شه ب عنوان مشاهده‌گر" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "اضافه‌ شده به عنوان عضو" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "نظر" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "اشاره شده در نظر" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "تاریخ و ساعت ایجاد" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "موارد تاریخچه" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "آگاه کردن کاربران" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "بررسی‌شده" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "آگاه کردن کاربران و پروژه‌های مشخص‌شده فعال است" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "مقدار نامعتبر برای سطح آگاهی‌رسانی" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"Epic بروز شد\n" +"سلام %(user)s, %(changer)s epic را در %(project)sآپدیت کرده است\n" +"دیدن epic #%(ref)s %(subject)s در %(url)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] epic را آپدیت کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] epic ایجاد کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] epic حذف کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] موضوع آپدیت کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] موضوع ایجاد کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] موضوع حذف کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] آپدیت کرده است \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] پیشرفت ایجاد کرده است \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] پیشرفت حذف کرده است \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] وظیفه آپدیت کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] وظیفه ایجاد کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] وظیفه حذف شده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] استوری کاربری آپدیت کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] استوری کاربری ایجاد کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] استوری کاربری حذف کرده است #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] صفحه‌ی ویکی آپدیت کرده است \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] صفحه‌ی ویکی ایجاد کرده است \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] صفحه‌ی ویکی حذف کرده است \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "بررسی‌کنندگان شامل کاربر نامعتبر است" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "نسخه می‌بایست یک عدد باشد" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "پارامتر نسخه نامعتبر است" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "نسخه با نسخه‌ی فعلی یکسان نیست" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "نسخ" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"اگر مالک پروژه باشید یا ادمین دیگری وجود نداشته باشد، نمی‌توانید پروژه را ترک " +"کنید" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The user doesn't exist" +msgid "User does not exist." +msgstr "کاربر وجود ندارد" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "این کاربر در حال حاضر عضوی از پروژه است." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "آدرس ایمیل جدید" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "پروژه بدون مالک" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "به محدودیت تعداد اعضای پروژه‌های خصوصی رسیده‌اید" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "به محدودیت تعداد اعضای پروژه‌های عمومی رسیده‌اید" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "به محدودیت تعداد عضویت‌های معلق رسیده‌اید" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "پیشرفت‌های آینده" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "پایان پروژه" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "توکن نامعتبر است" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "توکن منقضی شده است" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "خط زمان " + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "حماسه‌ها" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "بک‌لاگ" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "کانبان" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "مسائل" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "ویکی‌تیم" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "شما به این قسمت دسترسی ندارید" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "تگ نامعتبر '{value}'. رنگ کد HEX معتبر نیست یا خالی است." + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"تگ نامعتبر '{value}'. می‌بایست نام یا یک جفت '[\"name\", \"hex color/\" | " +"null]' باشد." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "تگ نامعتبر '{value}'. می‌بایست نام تگ باشد." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "تگ‌ها" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "رنگ تگ‌ها" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "این تگ در حال حاضر موجود است." + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "این رنگ یک کد HEX معتبر نیست." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "تگ موجود نیست." + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "دسترسی لازم برای تنظیم پیشرفت این وظیفه را ندارید." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "دسترسی لازم برای تنظیم استوری کاربری این وظیفه را ندارید." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "دسترسی لازم برای تعیین وضعیت این وظیفه را ندارید." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "ترتیب استوری‌های کاربری" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "ترتیب وظایف بورد وظیفه" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "یوکائین است" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "شناسه‌ی نامعتبر مرحله‌ی پیشرفت" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "شناسه‌ی نامعتبر وضعیت وظیفه" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "شناسه‌ی نامعتبر استوری کاربری" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "شناسه‌ی نامعتبر وضعیت وظیفه. وضعیت می‌بایست به همان پروژه متعلق باشد." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" +"شناسه‌ی نامعتبر استوری کاربری. استوری کاربری می‌بایست به همان پروژه متعلق باشد." + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" +"شناسه‌ی نامعتبر مرحله‌ی پیشرفت. مرجله‌ی پیشرفت می‌بایست به همان پروژه متعلق باشد." + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" +"شناسه‌های نامعتبر وظیفه. تمام وظایف می‌بایست به همان پروژه تعلق داشته باشند، " +"در صورت وجود به همان وضعیت، استوری کاربری و یا مرحله‌ی پیشرفت متعلق باشند" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "تمامی وظیفه‌ها باید از پروژه یکسان باشند" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "شخصی" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

و چند کلمه از همکاران
که شما را دعوت کرده‌اند

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "پذیرش دعوت به تایگا" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "پذیرش دعوت" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"و حالا چند کلمه از همکاران خوبی که شما را دعوت کرده‌اند:\n" +"\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "دعوت به تایگا را با کلیک کردن روی این لینک بپذیرید:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] دعوتنامه برای ملحق شدن به پروژه‌ی '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] به پروژه اضافه اضافه شد '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s می‌گوید:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

از این پس، وضعیت جدید شما در این پروژه، \"ادمین\" خواهد بود.

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s می‌گوید:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"از این پس وضعیت جدید شما در این پروژه، \"ادمین\" خواهد بود.\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] انتقال مالکیت پروژه تأیید شد!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s می‌گوید:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

اگر بخواهید می‌توانید مالکیت پروژه را به شخص دیگری منتقل کنید.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "درخواست انتقال به شخصی دیگر" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s می‌گوید:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"اگر بخواهید می‌توانید مالکیت پروژه را به شخص دیگری منتقل کنید.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "درخواست انتقال مالکیت پروژه به شخصی دیگر:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] انتقال مالکیت پروژه رد شد\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

لطفاً روی \"ادامه\" کلیک کنید تا انتقال پروژه از پنل مدیریت آغاز " +"شود.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "ادامه" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"لطفاً برای آغاز کردن انتقال پروژه از طریق پنل مدیریت، به تنظیمات پروژه‌ی خود " +"بروید.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "رفتن به تنظیمات پروژه:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s] درخواست انتقال مالکیت پروژه\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

سلام %(receiver_name)s,

\n" +"

%(owner_name)s, مالک فعلی پروژه‌ی \"%(project_name)s\" می‌خواهد شما " +"مالک جدید پروژه شوید.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s می‌گوید:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

لطفاً برای پذیرش را رد این پرپوزال روی دکمه‌ی \"ادامه\" کلیک کنید.\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"سلام %(receiver_name)s,\n" +"%(owner_name)s, مالک فعالی پروژه‌ی \"%(project_name)s\" می‌خواهد شما مالک جدید " +"پروژه شوید.\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s می‌گوید:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"لطفاً برای پذیرش یا رد کردن این پرپوزال روی لینک کلیک کنید.

\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "پذیرش یا رد کردن انتقال مالکیت پروژه:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s] پیشنهاد انتقال مالکیت پروژه\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "اسکرام" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"لیست امور ناتمام سریع در اسکرام با توجه به لیست ویژگی‌ها اولویت‌بندی شده و " +"شامل توضیحی کوتاه در مورد محصول می‌شود. زمانی که از اسکرام استفاده می‌کنید، " +"نیازی به شروع کردن پروژه با مستندسازی طولانی و پیشاپیش تمام نیازها نیست. در " +"طول پروژه با کسب اطلاعات بیشتر در مورد محصول و مشتری، لیست امور ناتمام محصول " +"در اسکرام می‌تواند گسترش پیدا کرده و تغییر کند." + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"کانبان روشی برای مدیریت کار اطلاعاتی است که روی تحویل در زمان لازم بدون فشار " +"اضافی روی اعضای تیم تأکید دارد. در این روش، فرآیند از تعریف وظیفه تا تحویل " +"آن به مشتری، برای مشترکین قابل‌مشاهده است و اعضای تیم کارها را از صف کاری " +"برمی‌دارند." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "؟" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "جدید" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "آماده" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "در حال انجام" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "آماده برای تست" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "انجام‌شده" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "آرشیوشده" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "بسته شده" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "نیازمند اطلاعات" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "به تعویق افتاده" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "رد شده" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "باگ" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "سوال" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "بهینه‌سازی" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "کم" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "معمولی" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "زیاد" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "لیست اهداف" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "جزئی" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "مهم" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "حیاتی" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "طراحی" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "جلو" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "پشت" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "مالک محصول" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "ذینفع" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"شما دسترسی لازم برای تنظیم این پیشرفت برای این استوری کاربری را ندارید." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "شما دسترسی لازم برای انتخاب وضعیت این استوری کاربری را ندارید." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "شناسه‌ی نقش نامعتبر '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "شناسه‌ی امتیاز نامعتبر '{points_id}'" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "ایجاد استوری کاربری #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "نقش" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "ترتیب لیست امور ناتمام" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "ترتیب پیشرفت‌ها" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "ترتیب کانبان" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "تاریخ تکمیل" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "کاربران متصل شده" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "ایجاد شده از موضوع" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "ساخته‌شده بر اساس وظیفه" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "استوری کاربری با این شناسه وجود ندارد" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"شناسه‌ی وضعیت استوری کاربری نامعتبر است. وضعیت می‌بایست به همان پروژه مربوط " +"باشد." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"شناسه‌ی نامعتبر استوری کاربری. استوری کاربری می‌بایست به همان پروژه متعلق باشد." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"شناسه‌ی نامعتبر استوری کاربری. استوری کاربری می‌بایست به همان پروژه متعلق باشد." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "پروژه‌ای با این شناسه وجود ندارد." + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "کاربر هنوز عضو پروژه نشده است." + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "نقش نامعتبر برای پروژه" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "کاربر می‌بایست مخاطب معتبر باشد" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "مالک پروژه می‌بایست ادمین باشد." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "حداقل یک کاربر می‌بایست ادمین فعال پروژه باشد." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "شناسه‌ی نقش نامعتبر است. تمام نقش‌ها می‌بایست به همان پروژه مربوط باشند." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "گزینه‌های پیش‌فرض" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "وضعیت‌های استوری‌های کاربری" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "امتیاز" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "وضعیت‌های وظایف" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "وضعیت‌های موضوعات" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "انواع موضوعات" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "اولویت‌ها" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "درجات اهمیت" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "نقش‌ها" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "رأی‌ها" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "رأی" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "پارامتر 'content' الزامی است" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "پارامتر 'project_id' الزامی است" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "آخرین ویرایش‌کننده" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "برای بررسی تفاوت‌های دقیق از API سوابق استفاده کنید" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "عضو پروژه" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "اعضای پروژه" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "شناسه" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "مالکیت پروژه" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "مالکیت‌های پروژه" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "اعضا" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "پایان پروژه" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "پایان پروژه" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "ایمیل تکراری" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "نام کاربری یا ایمیل نامعتبر" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "رمز عبوری فعلی ضروری است" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "پسورد جدید ضروری است" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "رمز عبور فعلی نامعتبر است" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"نامعتبر، آیا مطمئن هستید که توکن صحیح است و قبلاً آن را استفاده نکرده‌اید؟" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "نامعتبر، آیا مطمئن هستید که توکن صحیح است؟" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "ارسال ایمیل با موفقیت انجام شد!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "وضعیت سوپریوزر" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"برای صدور تمام دسترسی‌های کاربر بدون تعیین کردن جداگانه‌ی هر دسترسی به کار " +"می‌رود." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "نام کاربری" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "لازم است. 30 حرف یا کمتر. حروف، اعداد و کاراکترهای /./-/_" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "یک نام کاربری معتبر وارد کنید" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "فعال" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"برای انتخاب وضعیت کاربر به صورت اکتیو یا فعال به کار می‌رود. به جای حذف کردن " +"حساب‌های کاربری، تیک آن را حذف کنید." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "زندگی‌نامه" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "تصویر" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "تاریخ عضویت" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "تاریخ عضویت" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "قبول شرایط" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "خواندن شرایط جدید" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "زبان پیش‌فرض" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "قالب پیش‌فرض" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "مختصات زمانی پیش‌فرض" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "رنگی کردن تگ‌ها" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "ایمیل توکن" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "آدرس ایمیل جدید" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "حداکثر تعداد پروژه‌ها" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "حداکثر تعداد پروژه‌های عمومی" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "حداکثر تعداد اعضا برای هر پروژه‌ی خصوصی" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "حداکثر تعداد اعضا برای هر پروژه‌ی عمومی" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "دسترسی‌ها" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "نام کاربری یا رمز عبور اشتباه است" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] تغییر ایمیل" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] بازیابی رمز عبور" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" توجه: می‌خواهید حساب کاربری خود را از این سرویس حذف کنید " +"اینجا کلیک کنید\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"توجه: می‌خواهید حساب خود را از این سرویس حذف کنید: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "شما در تایگا ثبت نام کردید!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "نامعتبر" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "نام کاربری نامعتبر. نام کاربری دیگری انتخاب کنید." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "خواندن شرایط جدید باید صحیح باشد" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "مقدار تکراری مجاز نیست. مقدار '{}' قبلاً استفاده شده است." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "مقدار کلیدی" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "لینک" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "کلید امنیتی" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "کد وضعیت" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "داده‌های درخواست" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "هدرهای درخواست" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "داده‌های پاسخ" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "هدرهای پاسخ" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "مدت زمان" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "آی پی آدرس غیر مجاز" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "" +#~ "شناسه‌ی مرحله‌ی پیشرفت نامعتبر است. مرحله‌ی پیشرفت می‌بایست به همان پروژه " +#~ "تعلق داشته باشد." + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "" +#~ "شناسه‌ی استوری کاربری نامعتبر است. تمام استوری‌های کاربری می‌بایست به همان " +#~ "پروژه تعلق داشته باشند و در صورت وجود، به همان وضعیت و مرحله‌ی پیشرفت " +#~ "مربوط باشند." + +#~ msgid "Personal info" +#~ msgstr "اطلاعات شخصی" + +#~ msgid "Permissions" +#~ msgstr "دسترسی‌ها" + +#~ msgid "Restrictions" +#~ msgstr "محدودیت‌ها" + +#~ msgid "Important dates" +#~ msgstr "تاریخ‌های مهم" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/fi/LC_MESSAGES/django.po b/taiga/locale/fi/LC_MESSAGES/django.po new file mode 100644 index 000000000..cfb2319de --- /dev/null +++ b/taiga/locale/fi/LC_MESSAGES/django.po @@ -0,0 +1,4914 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# artol , 2015 +# artol , 2015 +# David Barragán , 2015 +# Sami Singh , 2016 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-02-26 11:31+0000\n" +"Last-Translator: Taiga Dev Team \n" +"Language-Team: Finnish (http://www.transifex.com/taiga-agile-llc/taiga-back/" +"language/fi/)\n" +"Language: fi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "väärä kirjautumistyyppi" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "Ei löytynyt" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "Virheellinen rooli projektille" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "Tunniste on virheellinen" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "tuntematon käyttäjänimi" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" +"Pakollinen. Korkeintaan 255 merkkiä. Kirjaimia, numeroita /./-/_ merkkejä'" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Käyttäjänimi on varattu." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Sähköposti on jo varattu." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Käyttäjä on jo rekisteröitynyt." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "Tunniste on virheellinen" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token has no id" +msgstr "Tunniste on virheellinen" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Tunniste on virheellinen" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Pakollinen kenttä." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Virheellinen arvo." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' pitää olla True tai False." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Anna kelvollinen 'avain' joka koostuu merkeistä, numeroista, alaviivoista ja " +"tavuviivoista." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "Valitse kelvollinen valinta. %(value)s ei ole yksi vaihtoehdoista." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Anna voimassaoleva sähköpostiosoite." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "Päivämäärä on väärässä muodossa. Käytä yhtä näistä muodoista: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "Päiväys on väärässä muodossa. Käytä yhtä näistä muodoista: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "Aika on väärässä muodossa. Käytä yhtä näistä muodoista: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Anna kokonaisluku." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Varmista että arvo on korkeintaan %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Varmista että arvo on vähintään %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" pitää olla desimaaliluku." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Anna numero." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Anna korkeintaan %s numeroa yhteensä." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Desimaaleja voi olla korkeintaan %s." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Ennen desimaalipistettä saa olla korkeintaan %s numeroa." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Tiedostoa ei lähtetty. Varmista merkistö lomakkeella." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Tiedostoa ei lähetetty." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Tiedosto oli tyhjä." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Tiedoston nimi saa olla korkeintaan %(max)d pitkä se on nyt %(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "Valitse tiedosto tai Poista valintaneliö, ei molempia." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Anna kelvollinen kuva. Annettu ei ollut tunnistettava kuva tai se oli " +"vioittunut." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Estetty elementti" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "Sivu ei ole 'viimeinen', ekä sitä pystytä muuntamaan numeroksi." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Virheellinen sivu (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Virheellinen oikeuksien määrittely." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Virheellinen pk '%s' - sitä ei löydy." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Väärä tyyppi. Odotetaan pk-arvoa, vastaanotettu %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Kohdetta jossa %s=%s ei ole." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Viallinen linkki - URL ei löydy" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Viallinen linkki - URL ei löydy" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Virheellinen linkki konfiguraatiovirheen takia" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Virheellinen linkki - kohdetta ei löydy." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Väärä tyyppi. Odotan URL-merkkijonoa, sain %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Virheellinen data" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Syöte puuttuu" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "En voi luoda uutta kohdetta, vain olemassaolevat voidaan päivittää." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Anna lista kohteista." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Ei löytynyt" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Ei käyttöoikeutta" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Palvelinsovelluksen virhe" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Yhteysvirhe." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Virheellinen pyyntö." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Virheelliset tunnistautumistiedot." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Kirjautumistiedot puuttuvat." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Sinulla ei ole tähän oikeuksia." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Method '%s' not allowed." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Could not satisfy the request's Accept header" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Unsupported media type '%s' in request." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Request was throttled." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Tulee saataville %d sekunttia %s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Odottamaton virhe" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Ei löytynyt." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Method not supported for this endpoint." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Väärät argumentit." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Data validation error" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Integrity Error for wrong or invalid arguments" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Precondition error" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Ei enää tilaa uusille projekteille." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Error in filter params types." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project' must be an integer value." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Olet Taigatettu" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Seuraa meitä Twitterissä" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Lataa koodi GitHubista" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Päivityksiä" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Päivityksiä" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

kommentti:

\n" +"

%(comment)s

" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +"Kommentti: %(comment)s" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Tarvitsemme ainakin yhden roolin" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Tarvitaan tiedosto" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Virheellinen tiedostomuoto" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "virhe projektidatan tuonnissa" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "virhe roolien tuonnissa" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "virhe jäsenyyksien tuonnissa" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "virhe atribuuttilistan tuonnissa" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "virhe omien arvojen tuonnissa" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "virhe kierroksien tuonnissa" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "virhe pyyntöjen tuonnissa" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "virhe käyttäjätarinoiden tuonnissa" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "virhe tehtävien tuonnissa" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "virhe wiki-sivujen tuonnissa" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "virhe viki-linkkien tuonnissa" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "virhe avainsanojen sisäänlukemisessa" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "virhe aikajanojen tuonnissa" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "odottamaton virhe projektia tuotaessa" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Virhe tiedoston luonnissa" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Virhe tiedoston latauksessa" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] Projektistasi on luotu tiedosto." + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Projetkisi tiedosto on luettu sisään" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" ei löytynyt tästä projektista" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Virheellinen sisältä, pitää olla muodossa {\"avain\": \"arvo\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "nimi" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "kuvaus" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Väärä tunniste" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "koko nimi" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "sähköpostiosoite" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "kommentti" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "luontipvm" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Palaute

\n" +"

Taiga on vastaanottanut palautteen käyttäjältä %(full_name)s " +"<%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Kommentti

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Lisätiedot" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- Keneltä: %(full_name)s <%(email)s>\n" +"---------\n" +"- Kommentti:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Lisätiedot:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Palautetta käyttäjältä %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Projektia ei löydy" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Virheellinen allekirjoitus" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Virheellinen pyynnön kommentin tieto" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Virheellinen pyynnön tieto" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Viitattu elementtiä ei löydy" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Tilaa ei löydy" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Katso projektia" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Katso virstapylvästä" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Katso käyttäjätarinoita" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Katso tehtäviä" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Katso pyyntöjä" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Katso wiki-sivuja" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Katso wiki-linkkejä" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Lisää virstapylväs" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Muokkaa virstapyvästä" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Poista virstapylväs" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Katso käyttäjätarinaa" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Lisää käyttäjätarina" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Muokkaa käyttäjätarinaa" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Poista käyttäjätarina" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Lisää tehtävä" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Muokkaa tehtävää" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Poista tehtävä" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Lisää pyyntö" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Muokkaa pyyntöä" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Poista pyyntö" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Lisää wiki-sivu" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Muokkaa wiki-sivua" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Poista wiki-sivu" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Lisää wiki-linkki" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Muokkaa wiki-linkkiä" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Poista wiki-linkki" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Muokkaa projekti" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Poista projekti" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Lisää jäsen" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Poista jäsen" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Hallinnoi projektin arvoja" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Hallinnoi rooleja" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "omistaja" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Puutteelliset argumentit" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Väärä kuvaformaatti" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "Pakollinen kenttä." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "projekti" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "sisältötyyppi" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "objekti ID" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "muokkauspvm" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "liite" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "on poistettu" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "order" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "tyyppi" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "arvot" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "käyttäjätarina" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "tehtävä" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "pyyntö" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Nimi on jo olemassa" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "viittaus" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "tila" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "aihe" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "väri" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "tekijä" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "on asiakkaan vaatimus" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "on tiimin vaatimus" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "ulkoinen viittaus" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Kommentti on jo poistettu" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Kommenttia ei poistettu" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Muokkaa" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Luo" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Poista" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s roolipistettä" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "keneltä" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "kenelle" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Liitä tiedosto" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Päivitä tiedosto" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "poistettu" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "ei poistettu" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Poista liite" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "lisätty" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "poistettu" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Tekijä puuttuu" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-poistettu-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "kenelle:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "keneltä:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Lisätty" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Muutettu" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Poistettu" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "lisätty:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "poistettu:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Keneltä:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Kenelle:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "sisältö" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "suljettu muistiinpano" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Sinulla ei ole oikeuksia laittaa kierrosta tälle pyynnölle." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Sinulla ei ole oikeutta asettaa statusta tälle pyyntö." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "Sinulla ei ole oikeutta asettaa vakavuutta tälle pyynnölle." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "Sinulla ei ole oikeutta asettaa kiireellisyyttä tälle pyynnölle." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Sinulla ei ole oikeutta asettaa tyyppiä tälle pyyntö." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "vakavuus" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "kiireellisyys" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "virstapylväs" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "loppupvm" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "hukka-aika" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "arvioitu alkupvm" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "arvioitu loppupvm" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "on suljettu" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "disponibility" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "Alkuajan pitää olla ennen loppuaikaa." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "on lukittu" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' parametri on pakollinen" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' parametri on pakollinen" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "sähköposti" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "luo täällä" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "tunniste" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "kutsun lisäteksti" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "käyttäjäjärjestys" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "Käyttäjä on jo projektin jäsen" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "oletus Kt tila" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "oletuspisteet" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "oletus tehtävän tila" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "oletus kiireellisyys" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "oletus vakavuus" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "oletus pyynnön tila" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "oletus pyyntö tyyppi" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "jäsenet" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "virstapyväitä yhteensä" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "käyttäjätarinan yhteispisteet" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "aktiivinen odottavien paneeli" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "aktiivinen kanban-paneeli" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "aktiivinen wiki-paneeli" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "aktiivinen pyyntöpaneeli" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "videokokous järjestelmä" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "luo mallipohja" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "on yksityinen" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "vieraan oikeudet" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "käyttäjän oikeudet" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "päivityspvm" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "moduulien asetukset" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "on arkistoitu" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "työn alla olevien max" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "arvo" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "oletus omistajan rooli" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "oletus optiot" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "kt tilat" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "pisteet" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "tehtävän tilat" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "pyyntöjen tilat" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "pyyntötyypit" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "kiireellisyydet" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "vakavuudet" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "roolit" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "luontipvm" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "historian kohteet" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "ilmoita käyttäjille" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Ilmoita olemassaolosta määritellyille käyttäjille ja projektille" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Päivitettiin pyyntöä #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Loi pyynnön #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Poistettu pyyntö #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Päivitti kierrosta \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Loi kierroksen \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Poistettu kierros \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Päivitti tehtävää #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Tehtävä luotu #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Tehtävä poistettu #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] päivitti käyttäjätarinaa #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] loi käyttäjätarinan #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Poisti käyttäjätarinan #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Päivitettiin wiki-sivu \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Luotiin wiki-sivu \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Poistettiin wiki-sivu \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Vahdit sisältävät virheellisiä käyttäjiä" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "Versio pitää olla kokonaisluku" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Versio ei ole sama kuin nykyinen" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "versio" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The project doesn't exist" +msgid "User does not exist." +msgstr "Projektia ei löydy" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Tämä käyttäjä on jo projektin jäsen." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "uusi sähköpostiosoite" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Tuleva kierros" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Projektin loppu" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Tunniste on virheellinen" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "avainsanat" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "avainsanojen värit" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "kt järjestys" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "tehtävätaulun järjestys" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "on hidaste" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "joku" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

Tässä muutama sana henkilöltä
joka kutsui sinut
\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Hyväksy kutsu Taigaan" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Hyväksy kutsu" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"Tässä muutama sana henkilöltä joka kutsui sinut:\n" +"\n" +"%(extra)s" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Hyväksy kutsusi Taikaan linkistä: " + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Kutsu projektiin '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Lisätty projektiin '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Ketterä odottavien käyttäjätarinoiden lista Scrumissa on priorisoitu " +"ominaisuuslista, joka sisältää lyhyet kuvaukset kaikesta tuotteen halutuista " +"toiminnallisuuksista. Scrumissa ei tarvitse aloittaa raskasta " +"etukäteisdokumentointia halutuista ominaisuuksista. Odottavien lista voi " +"kasvaa ja muuttua kun tuotteesta ja asiakkaista opitaan lisää." + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban on tapa hallita tietotyötä joka korostaa juuri oikeaan aikaan " +"toimittamista ilman raskasta ylläpitoa projektitiimille. Tässä prosessi " +"tehtävän määrittelystä toimitukseen on näkyvissä asiakkaalle ja tiimin " +"jäsenille." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Uusi" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Valmis" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "Työn alla" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Valmis testattavaksi" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Tehty" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Arkistoitu" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Suljettu" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Tarvitsee lisätietoja" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Siirretty odottamaan" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Hylätty" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Virhe" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Kysymys" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Uusi ominaisuus" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Matala" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normaali" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Korkea" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Toivelista" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Vähäpätöinen" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Tärkeä" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Kriittinen" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "Käyttäjäkokemus" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Suunnittelu" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Edusta" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Palvelin" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Tuoteomistaja" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Sidosryhmä" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "rooli" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "odottavien listan järjestys" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "kierros järjestys" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "loppupvm" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "luotu pyynnöstä" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "En löydä käyttäjätarinaa tällä id:llä" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "En löydä projektia tällä id:llä" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Virheellinen rooli projektille" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Oletusoptiot" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Käyttäjätarinatilat" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Pisteet" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Tehtävien tilat" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Pyyntöjen tilat" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "pyyntötyypit" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Kiireellisyydet" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Vakavuudet" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Roolit" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Ääniä" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Äänestä" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content' parametri on pakollinen" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id' parametri on pakollinen" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "viimeksi muokannut" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "jäsenet" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Projektin loppu" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Projektin loppu" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Sähköposti on jo olemassa" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Tuntematon käyttäjänimi tai sähköposti" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Nykyinen salasanaparametri tarvitaan" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Uusi salasanaparametri tarvitaan" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Virheellinen nykyinen salasana" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Virheellinen. Oletko varma, että tunniste on oikea ja et ole jo käyttänyt " +"sitä?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Virheellinen, oletko varma että tunniste on oikea?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "Sähköposti lähetetty." + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "pääkäyttäjän status" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Kertoo että käyttäjä saa tehdä kaiken ilman erikseen annettuja oiekuksia." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "käyttäjänimi" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" +"Vaaditaan. Korkeintaan 30merkkiä. Kirjaimet, numerot ja merkit /./-/_ " +"sallittuja" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Anna olemassa oleva käyttäjänimi." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "aktiivinen" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Käyttäjä on aktiivinen. Poista aktiivisuus käyttäjän poistamisen sijaan." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografia" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "kuva" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "liittymispvm" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "liittymispvm" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "oletuskieli" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "oletus aikavyöhyke" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "väritä avainsanat" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "sähköpostitunniste" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "uusi sähköpostiosoite" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "oikeudet" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Käyttäjätunnus tai salasana eivät ole oikein." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Vaihda sähköposti" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Salasanan palautus" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" Voit poistaa tunnuksesi tästä palvelusta tästä\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Voit poistaa tunnuksesi tästä palvelusta: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Olet nyt Taigatettu!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "virheellinen" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Tuntematon käyttäjänimi, yritä uudelleen." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"Duplicate key value violates unique constraint. Key '{}' already exists." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "key" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "secret key" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "status code" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "request data" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "request headers" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "response data" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "response headers" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "duration" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Personal info" +#~ msgstr "Henkilökohtaiset tiedot" + +#~ msgid "Permissions" +#~ msgstr "Oikeudet" + +#~ msgid "Important dates" +#~ msgstr "Tärkeät päivämäärät" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/fr/LC_MESSAGES/django.po b/taiga/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 000000000..415788ba9 --- /dev/null +++ b/taiga/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,5784 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# 145d476c91450a2a8706fd7b160b27d7_b2328b5 , 2015 +# Abdelouahad Megder , 2016 +# Alain Poirier , 2015 +# Charles Bonnet , 2017 +# David Barragán , 2015 +# Djyp Forest Fortin , 2015 +# Donatien Schmitz , 2018 +# eldk , 2017 +# eldk , 2017 +# Florent B. , 2015 +# Gary , 2017 +# Gautier Ferandelle , 2016 +# jerome marchini , 2017 +# John Weetaker , 2017 +# Laurent Cabaret , 2016 +# Louis-Michel Couture , 2015 +# madmarsu , 2017 +# madmarsu , 2017 +# Matthieu Durocher , 2015 +# 145d476c91450a2a8706fd7b160b27d7_b2328b5 , 2015 +# Nicolas Minelle , 2016 +# Nico , 2020 +# Nlko , 2015 +# Regis TEDONE , 2015 +# Sébastien Talbot , 2016 +# Simon Leblanc , 2021 +# Stéphane Mor , 2015 +# Thierno Rignoux , 2016 +# William Godin , 2015 +# Yoel Pepin , 2019 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2023-11-11 14:37+0000\n" +"Last-Translator: Louis Chance \n" +"Language-Team: French \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 5.2-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "type d'identifiant invalide" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "L'inscription publique est désactivée." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" +"Vous devez accepter nos Conditions générales d'utilisation et notre " +"politique de confidentialité" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "type d'inscription invalide" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" +"L'en-tête d'autorisation doit contenir deux valeurs séparées par un espace" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "Le jeton fourni n'est pas valide pour aucun type de jeton" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" +"Le jeton ne contenait aucune identification d'utilisateur reconnaissable" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "Utilisateur·rice non trouvé·e" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "L'utilisateur·rice est inactif·ve" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "Type d'algorithme '{}' non reconnu·e" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" +"Vous devez avoir la bibliothèque de cryptographie installée pour utiliser {}." + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "Algorithme spécifié non valide" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "Jeton invalide ou expiré" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "nom d'utilisateur invalide" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" +"Requis. 255 caractères ou moins. Lettres, chiffres et caractères /./-/_'" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Nom complet invalide" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" +"Aucun compte actif trouvé avec les informations d'identification fournies" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Ce nom d'utilisateur est déjà utilisé." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Cette adresse email est déjà utilisée." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Cet utilisateur est déjà inscrit." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Erreur à la création de l'utilisateur." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "jti" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "utilisateur" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "créé le" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "expire à" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "Liste de révocation de jetons" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "Impossible de créer un jeton sans type ni durée de vie" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "Le jeton n'a pas d'identifiant" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "Le jeton n'a pas de type" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "Le jeton a un type incorrect" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "Le jeton n'a pas de revendication '{}'" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "La revendication '{}' du jeton a expiré" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "Le jeton est sur la liste de révocation" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Ce champ est requis." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Valeur invalide." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "La valeur de '%s' doit être soit Vrai soit Faux." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Entrez un 'slug' valide composé de lettres, chiffres, tirets bas ou traits " +"d'union." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"Sélectionnez une option valide. %(value)s ne fait pas partie des choix " +"possibles." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Le nom de domaine de votre émail n'est pas autorisé" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Entrez une adresse email valide." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" +"Le format de la date est incorrect. Veuillez utiliser un de ces formats à la " +"place : %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" +"Le format de l'horodatage est incorrect. Veuillez utiliser un de ces formats " +"à la place : %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" +"Le format de l'heure est incorrect. Veuillez utiliser un de ces formats à la " +"place : %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Entrez un nombre entier." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" +"Assurez-vous que cette valeur est inférieure ou égale à %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" +"Assurez-vous que cette valeur est supérieure ou égale à %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "La valeur de \"%s\" doit être un nombre en virgule flottante." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Entrez un nombre." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Assurez-vous qu'il n'y a pas plus de %s chiffres au total." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Assurez-vous qu'il n'y a pas plus de %s décimales." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Assurez-vous qu'il n'y a pas plus de %s chiffres avant le point." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Aucun fichier n'a été soumis. Vérifiez l'encodage sur le formulaire." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Aucun fichier n'a été soumis." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Le fichier soumis est vide." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Assurez-vous que le nom de fichier comporte au plus %(max)d caractères (il " +"en a %(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" +"Veuillez soit soumettre un fichier ou cocher la case de remise à zéro, mais " +"pas les deux." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Envoyez une image valide. Le fichier que vous avez envoyé n'était pas une " +"image ou était une image corrompue." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Élément bloqué" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" +"La page n'est pas la \"dernière\", et ne peut pas non plus être convertie en " +"entier." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Page invalide (%(page_number)s) : %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Définition de permission invalide." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Pk '%s' invalide - l'objet n'existe pas." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Type incorrect. Valeur pk attendue, %s reçu." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "L'objet pour lequel %s=%s n'existe pas." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Hyperlien invalide - aucune correspondance d'URL" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Hyperlien invalide - Correspondance d'URL incorrecte" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Hyperlien invalide dû à une erreur de configuration" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Hyperlien invalide - l'objet n'existe pas." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Type incorrect. Chaîne URL attendu, %s reçu." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Donnée invalide" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Aucune entrée fournie" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Impossible de créer un nouvel élément, seuls les éléments existants peuvent " +"être mis à jour." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Une liste d'éléments était attendue." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Non trouvé" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Permission refusée" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Erreur du serveur d'application" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Erreur de connexion." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Requête mal formée." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Informations de connexion incorrects." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Informations d'authentification manquantes." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Vous n'avez pas l'autorisation d'effectuer cette action." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "La méthode %s n'est pas autorisée." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Impossible de satisfaire l'en-tête Accept" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Type de média %s non pris en charge dans la requête." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "La requête a été limitée." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Disponible dans %d seconde%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Erreur inattendue" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Non trouvé." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Méthode non supportée par ce point d'entrée." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Arguments invalides." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Erreur de validation des données" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Erreur d'intégrité ou arguments invalides" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Erreur de précondition" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Limite de projets atteinte." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s n'est pas une liste" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Erreur dans les types de paramètres de filtres." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project' doit être une valeur entière." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Vous venez de vous faire Taigatiser" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

Bienvenue dans " +"%(product_name)s, un outil de gestion de projet Agile Open Source

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" Configurer les notifications " +"par mail ou se désinscrire\n" +"  • \n" +" Support de Taiga\n" +"  • \n" +" Nous contacter\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Suivez-nous sur Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Téléchargez le code sur GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Mises à jour" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Mises à jour" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

commentaire :" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Commentaire : %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Erreur d'accès à l'hôte" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "Erreur Adresse IP" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "Récit utilisateur créé" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "Récit utilisateur modifié" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "Récit utilisateur supprimé" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "Historique des utilisateurs #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Tâche crée" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Tâche modifiée" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Tâche supprimée" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Tâche #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Ticket créé" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Ticket modifié" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Ticket supprimé" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Ticket : #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Page Wiki crée" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Page Wiki modifiée" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Page Wiki supprimée" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Page Wiki : {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Sprint créé" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Sprint modifié" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Sprint supprimé" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Sprint : {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Veuillez sélectionner au moins un rôle" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Fichier d'export nécessaire" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Format d'export non valide" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "Erreur lors de l'import de données" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "Erreur à l'importation des rôles" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "Erreur à l'importation des groupes d'utilisateurs" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "Erreur lors de l'import des listes des attributs de projet" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "erreur d'importation des valeurs d'attributs de projet par défaut" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "Erreur à l'importation des champs personnalisés" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "Erreur lors de l'importation des sprints" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "Erreur à l'importation des problèmes" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "Erreur à l'importation des récits utilisateur" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "Erreur d'importation des épopées" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "Erreur lors de l'importation des tâches" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "Erreur à l'importation des pages Wiki" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "Erreur à l'importation des liens Wiki" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "erreur lors de l'importation des mots-clés" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "erreur lors de l'import des timelines" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "Erreur imprévue à l'import du projet" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Vous avez atteint le nombre maximum de projets privés" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "Ce projet privé est le dernier que vous pouvez rejoindre" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Vous avez atteint le nombre maximum de projets publics" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "Ce projet public est le dernier que vous pouvez rejoindre" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Erreur dans la génération de l'export du projet" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"Erreur lors de la récupération des données de {user_full_name} <{user_email}" +"> :\"\n" +"\n" +"\n" +"RAISON :\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS :\n" +"--------\n" +"{details}\n" +"\n" +"TRACE DE L'ERREUR :\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Erreur au chargement de l'export du projet" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Erreur lors du chargement de votre fichier d'export de projet" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr " -- aucune information détaillée --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Export du projet généré

\n" +"

Bonjour %(user)s,

\n" +"

Votre export du projet %(project)s a bien été généré.

\n" +"

Vous pouvez le télécharger ici :

\n" +" Télécharger le fichier d'export\n" +"

Ce fichier sera supprimé le %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(user)s,\n" +"\n" +"Votre export du projet %(project)s a bien été créé. Vous pouvez les " +"télécharger ici :\n" +"\n" +"%(url)s\n" +"\n" +"Le fichier sera supprimé le %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] L'export de votre projet est disponible" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Bonjour %(user)s,

\n" +"

Votre projet %(project)s n'a pas été exporté correctement.

\n" +"

Les administrateurs du système Taïga ont été informés.
Veuillez " +"réessayer ou contacter le support à l'adresse suivante\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(user)s,\n" +"\n" +"%(error_message)s\n" +"Votre projet %(project)s n'a pas été exporté correctement.\n" +"\n" +"Les administrateurs du système Taïga ont été informés.\n" +"\n" +"Veuillez réessayer ou contacter le support à l'adresse suivante " +"%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Bonjour %(user)s,

\n" +"

Votre projet n'a pas été importé correctement.

\n" +"

Les %(product_name)s administrateurs du système ont été informés.
Veuillez réessayer ou contacter le support à l'adresse suivante\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Détails des erreurs

\n" +"
%(details)s
\n" +" " + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Votre projet n'a pas pu être importé.\n" +"\n" +"Les administrateurs de %(product_name)s en ont été informés.\n" +"\n" +"Vous pouvez tenter à nouveau d'importer votre projet ou prendre contact avec " +"notre équipe support via %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Projet importé

\n" +"

Bonjour %(user)s,

\n" +"

Votre projet a correctement été importé.

\n" +" Accéder au projet %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(user)s,\n" +"\n" +"Votre projet a correctement été importé.\n" +"\n" +"Vous pouvez accéder à votre projet %(project)s ici :\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Votre projet à été importé" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" non trouvé dans the projet" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Format non valide. Il doit être de la forme {\"cle\": \"valeur\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "Contient des champs personnalisés invalides." + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "Nom dupliqué" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" +"Une Épopée a un lien avec un récit utilisateur du projet externe " +"(%(project)s) et ne peut être importée" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Authentification requise" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "nom" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "Url de l'icône" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "description" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Url suivante" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "application" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Jeton invalide" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "Nom complet" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "Adresse email" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "Commentaire" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "Date de création" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Réaction

\n" +"

Taïga a reçu une réaction de %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Commentaire

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Informations supplémentaires" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- De : %(full_name)s <%(email)s>\n" +"---------\n" +"- Commentaire :\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Informations supplémentaires :" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Réaction de %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "Le contenu n'est pas un contenu JSON valide" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Le projet n'existe pas" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Signature non valide" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" +"[{user_name}]({user_url} \"Voir le profile de {user_name} sur {platform}\") " +"dit [{platform}#{number}]({comment_url} \"Aller au commentaire\") :\n" +"\n" +"\"{comment_message}\"" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"Commentaire de {platform} :\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Ignoré" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ticket créé par [{user_name}]({user_url} \"Voir le profil de {user_name} sur " +"{platform}\") sur [{platform}#{number}]({url} \"Aller au ticket\")." + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Problème créé depuis {platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ticket modifié par [{user_name}]({user_url} \"Voir le profil de {user_name} " +"sur {platform}\") sur [{platform}#{number}]({url} \"Aller au ticket\")." + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "Ticket modifié sur {platform}." + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ticket fermé par [{user_name}]({user_url} \"Voir le profil de {user_name} " +"sur {platform}\") sur [{platform}#{number}]({url} \"Aller au ticket\")." + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "Ticket fermé sur {platform}." + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Ticket ré-ouvert par [{user_name}]({user_url} \"Voir le profil de " +"{user_name} sur {platform}\") sur [{platform}#{number}]({url} \"Aller au " +"ticket\")." + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "Ticket ré-ouvert sur {platform}." + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Information incorrecte sur le problème" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "utilisateur inconnu" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} a changé le statut de [{platform} commit]({commit_url} \"Voir le " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Statut : **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"Statut changé depuis le commit sur {platform} \n" +"\n" +"- Status : **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"Ce {type_name} a été mentionné par {user_text} sur le [commit {platform}]" +"({commit_url} \"Voir le commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" +"Ce problème a été mentionné dans la participation sur {platform} " +"\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "L'élément référencé n'existe pas" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "L'état n'existe pas" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Les paramètres de projets sont nécessaires" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Requête API Asana invalide" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "Impossible de faire la requête à l'API Asana" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Paramètre de code nécessaire" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Erreur lors de l'import du projet Asana" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Données d'authentification non valides" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "Échec sur un service tiers" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "Erreur lors de l'import du projet GitHub" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "La paramètre d'URL est nécessaire" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" +"\n" +" Une erreur s'est produite ; probablement à cause d'une " +"version non supportée de Jira.\n" +" Les versions de Jira supérieures ou égales à la 8.6 ne sont " +"pas supportées." + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "project_type {} non valide" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "La configuration du serveur Jira n'est pas valide." + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "Token d'authentification non valide ou expiré" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Erreur lors de l'import du projet Jira" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "Erreur lors de l'import du projet PivotalTracker" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Projet Asana importé

\n" +"

Bonjour %(user)s,

\n" +"

Votre projet Asana a bien été importé.

\n" +" Accéder à %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(user)s,\n" +"\n" +"Votre projet Asana a bien été importé.\n" +"\n" +"Vous pouver voir le projet %(project)s ici :\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Votre projet Asana a été importé" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Projet GitHub Project importé

\n" +"

Bonjour %(user)s,

\n" +"

Votre projet GitHub a bien été importé.

\n" +" Accéder à %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(user)s,\n" +"\n" +"Votre projet GitHub a bien été importé.\n" +"\n" +"Vous pouvez accéder à votre projet %(project)s ici :\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] Votre projet GitHub a été importé" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Bonjour %(user)s,

\n" +"

Votre projet n'a pas été importé correctement.

\n" +"

Les administrateur %(product_name)s ont été informés.
Vous " +"pouvez tenter à nouveau votre import ou prendre contact avec notre équipe " +"support via\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Projet Jira importé

\n" +"

Bonjour %(user)s,

\n" +"

Votre projet Jira a bien été importé.

\n" +" Accéder à %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(user)s,\n" +"\n" +"Votre projet Jira a bien été importé.\n" +"\n" +"Vous pouvez accéder au projet %(project)s ici :\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Votre projet Jira a été importé" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Projet Trello importé

\n" +"

Bonjour %(user)s,

\n" +"

Votre projet Trello a bien été importé.

\n" +" Accéder à %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(user)s,\n" +"\n" +"Votre projet Trello a bien été importé.\n" +"\n" +"Vous pouvez accéder au projet %(project)s ici :\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Votre projet Trello a été importé" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "Requête non valide : %(text)s sur %(url)s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "Non autorisé : %(text)s sur %(url)s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "Ressource non disponible : %(text)s sur %(url)s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "Erreur lors de l'import du projet Trello" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Consulter le projet" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Voir les jalons" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Voir epic" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Voir les récits utilisateur" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Consulter les tâches" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Voir les problèmes" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Consulter les pages Wiki" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Consulter les liens Wiki" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Ajouter un jalon" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Modifier le jalon" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Supprimer le jalon" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Ajoutez une EPIC" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Modifier epic" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "Commenter epic" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Supprimer epic" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Voir le récit utilisateur" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Ajouter un récit utilisateur" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Modifier le récit utilisateur" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "Commenter le récit utilisateur" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Supprimer le récit utilisateur" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Ajouter une tâche" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Modifier une tâche" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Commenter tâche" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Supprimer une tâche" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Ajouter un problème" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Modifier le problème" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "Commenter problème" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Supprimer le problème" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Ajouter une page Wiki" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Modifier une page Wiki" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Commenter la page Wiki" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Supprimer une page Wiki" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Ajouter un lien Wiki" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Modifier un lien Wiki" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Supprimer un lien Wiki" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Modifier le projet" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Supprimer le projet" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Ajouter un membre" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Supprimer un membre" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Administrer les paramètres du projet" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Administrer les rôles" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "Visibilité" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "Modules" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Les valeurs par défaut" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "Activité" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "Fans" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "propriétaire" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Rendre publique" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} rendu(e)(s) publique(s) avec succès." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Rendre privé" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} rendu(e)(s) privé(e)(s) avec succès." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "arguments manquants" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "format de l'image non valide" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "Nom du gabarit invalide" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "Description du gabarit invalide" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "Identifiant utilisateur invalide" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "L'utilisateur n'existe pas" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "L'utilisateur doit déjà être un membre du projet" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "Le couloir par défaut ne peut pas être supprimé." + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" +"Vous ne pouvez pas supprimer la date d'échéance par défaut d'un récit " +"utilisateur" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "Le projet a déjà des dates d'échéance" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "Vous ne pouvez pas supprimer l'état d'échéance par défaut d'une tâche" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "Le projet a déjà des dates d'échéance pour les tâches" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" +"Vous ne pouvez pas supprimer l'état de l'échéance par défaut d'un ticket" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "Le projet a déjà des dates d'échéance" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" +"Pour ajouter des membres à un projet, vous devez d'abord vérifier votre " +"adresse de courriel" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" +"Cet utilisateur ne peut pas être supprimé des projets suivant : ces projets " +"n'auraient plus eu d'administrateur actif : {}." + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "Un email est requis" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"Le projet doit avoir un propriétaire et au moins l'un de ses membres doit " +"être un administrateur actif" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "Vous n'avez pas les permissions pour consulter cet élément." + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "Mises à jour partielles non supportées" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "Le numéro d'identification du ticket n'existe pas" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "L'identifiant du projet ne correspond pas entre l'objet et le projet" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "projet" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "type du contenu" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "identifiant de l'objet" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "état modifié" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "pièces jointes" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "est obsolète" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "depuis le commentaire" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "ordre" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" +"L'identifiant de pièce jointe à déplacer est invalide. La pièce jointe doit " +"appartenir au même élément (épopée, récit utilisateur, tâche, problème ou " +"page wiki)." + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" +"IDs de pièces jointes non valides. Toutes les pièces jointes doivent " +"appartenir au même élément (épopée, récit utilisateur, tâche, problème ou " +"page wiki)." + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "Whereby.com" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Personnalisé" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "Ce projet a été bloqué pour cause d'impayé" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "Ce projet a été bloqué par l'équipe administrative" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "Ce projet est bloqué car son propriétaire est parti" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "Ce projet est bloqué en attendant sa suppression" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +" %(full_name)s a " +"écris à %(project_name)s\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +" Vous recevez ce message car vous êtes enregistré comme " +"administrateur du projet %(project_name)s. Si vous ne souhaitez pas que les " +"membres de la communauté Taïga puissent contacter votre projet, merci de modifier vos préférence de projet pour " +"éviter ce type de contact. La communication classique entre les membres du " +"projet ne sera pas affectée.\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s a écris à %(project_name)s\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +"Vous recevez ce message car vous êtes enregistré comme administrateur du " +"projet %(project_name)s. Si vous ne souhaitez pas que les membres de la " +"communauté Taïga puissent contacter votre projet, merci de modifier vos " +"préférence de projet sur %(project_settings_url)s pour éviter ce type de " +"contact. La communication classique entre les membres du projet ne sera pas " +"affectée.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s a envoyé un message au sujet du projet " +"%(project_name)s\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Texte" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Texte multi-ligne" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "Texte Riche" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Date" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "Liste déroulante" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "Case à cocher" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "Nombre" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "type" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "valeurs" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "Épopée" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "récit utilisateur" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "tâche" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "problème" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Un élément de même nom existe déjà." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "Date d'échéance" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "Raison de l'échéance" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" +"Vous n'avez pas les autorisations pour modifier ce statut sur ce récit." + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "réf" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "état" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "Ordre des épopées" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "sujet" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "couleur" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "assigné à" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "est un requis client" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "est un requis de l'équipe" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "Récit Utilisateur" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "référence externe" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "Il n'y a pas de récit avec cet ID" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "Un commentaire est requis" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "les commentaires supprimés ne peuvent être modifiés" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Commentaire déjà supprimé" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Commentaire non supprimé" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Changement" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Créer" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Supprimer" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s points de rôle" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "de" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "à" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Ajouter une pièce jointe" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Pièces jointes mises à jour" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "obsolète" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "non obsolète" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Pièce jointe supprimée" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "ajouté" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "supprimé" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Non assigné" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "Non renseigné" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-supprimé-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "à :" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "de :" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Ajouté" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Modifié" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Supprimé" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "ajouté :" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "supprimé :" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "De :" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "A :" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "contenu" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "note bloquée" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "sprint" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Vous n'avez pas la permission d'affecter ce sprint à ce problème." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Vous n'avez pas la permission d'affecter ce statut à ce problème." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "Vous n'avez pas la permission d'affecter cette sévérité à ce problème." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "Vous n'avez pas la permission d'affecter cette priorité à ce problème." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Vous n'avez pas la permission d'affecter ce type à ce problème." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "sévérité" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "priorité" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "jalon" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "date de fin" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "Le jalon n'est pas valide pour le projet" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "Tous les tickets doivent être dans le même projet" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Aimer" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Aime" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "slug" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "date de démarrage estimée" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "date de fin estimée" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "est fermé" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "disponibilité" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "La date de démarrage doit être antérieure à la de fin prévisionnelle." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "Il n'y a pas de jalon avec cet ID" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "Tous les récits utilisateurs doivent provenir du même projet" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "est bloqué" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "Le paramètre de référence est nécessaire" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "Le paramètre projet ou projet_slug est nécessaire" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "Le paramètre de la requête \"moveTo\" est obligatoire" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" +"Impossible de définir le couloir sur Aucun s'il existe des couloirs " +"disponibles" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' paramètre obligatoire" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' paramètre obligatoire" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "L'utilisateur doit être un membre du projet." + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "email" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "Créé le" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "jeton" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "Text supplémentaire de l'invitation" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "classement utilisateur" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "L'utilisateur est déjà un membre du projet" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "Statut d'épopée par défaut" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "statut de l'HU par défaut" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "Points par défaut" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "Etat par défaut des tâches" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "Priorité par défaut" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "Sévérité par défaut" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "statut du problème par défaut" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "type de problème par défaut" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "couloir par défaut" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "logo" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "membres" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "total des jalons" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "total des points du récit" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "contact actif" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "panneau d'épopée actif" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "panneau backlog actif" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "panneau kanban actif" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "panneau wiki actif" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "panneau problèmes actif" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "plateforme de vidéoconférence" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "données complémentaires pour la salle de vidéoconférence" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "Modèle de création" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "est privé" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "Permissions anonymes" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "Permission de l'utilisateur" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "est mis en avant" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "est à la recherche de main d'oeuvre" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "en recherche de notes de membres" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "jeton de transfert de projet" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "code bloqué" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "date de mise à jour" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "total" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "fans la semaine dernière" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "fans le mois dernier" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "fans l'année dernière" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "activité de la semaine écoulée" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "activité du mois écoulé" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "activité de l'année écoulée" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "Configurations des modules" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "est archivé" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "limite de travail en cours" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "valeur" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "Par défaut" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "Nombre de jours jusqu'à la date d'échéance" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "Statut du récit utilisateur" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "rôle par défaut du propriétaire" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "options par défaut" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "statut d'épopée" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "statuts des us" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "dates d'échéance du récit utilisateur" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "points" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "états des tâches" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "échéances de la tâche" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "statuts des problèmes" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "types de problèmes" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "échéances du ticket" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "priorités" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "sévérités" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "rôles" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "attributs personnalisés d'apopée" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "Attributs personnalisés de récit" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "Attributs personnalisés de tâche" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "Attributs personnalisés de ticket" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Impliqué" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Toutes" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Aucun" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "Assigné" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "Mentionné" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "Ajouté en tant qu'observateur" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "Ajouté en tant que membre" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "Commentaire" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "Mentionné dans le commentaire" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "date de création" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "entrées de l'historique" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "notifier les utilisateurs" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Suivre" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "La notification existe pour l'utilisateur et le projet spécifiés" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Valeur non valide pour le niveau de notification" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" +"\n" +"

Épopée mise à jour

\n" +"

Bonjour %(user)s,
%(changer)s a mis à jour une épopée sur " +"%(project)s

\n" +"

Épopée #%(ref)s %(subject)s

\n" +" Voir l'épopée\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"Épopée mise à jour\n" +"Bonjour %(user)s, %(changer)s a modifié une épopée dans %(project)s\n" +"Voir l'épopée #%(ref)s %(subject)s à %(url)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] a mis à jour l'épopée #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nouvelle épopée créée

\n" +"

Bonjour %(user)s,
%(changer)s a créé une nouvelle épopée sur " +"%(project)s

\n" +"

Épopée #%(ref)s %(subject)s

\n" +" Voir l'épopée\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nouvelle épopée créée\n" +"Bonjour %(user)s, %(changer)s a créé une nouvelle épopée sur %(project)s\n" +"Voir l'épopée #%(ref)s %(subject)s dans %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Création de l'épopée #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Épopée supprimée

\n" +"

Bonjour %(user)s,
%(changer)s a supprimé une épopée sur " +"%(project)s

\n" +"

Épopée #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Épopée supprimée\n" +"Bonjour %(user)s, %(changer)s a supprimé un épopée sur %(project)s\n" +"Épopée #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Suppression de l'épopée #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" +"\n" +"

Ticket mis à jour sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a mis à jour un ticket :

\n" +"

#%(ref)s %(subject)s

\n" +" Voir le ticket\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" +"\n" +"Ticket mis à jour sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a mis à jour un ticket :\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Voir le ticket dans %(url)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Ticket #%(ref)s \"%(subject)s\" mis à jour\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Un nouveau ticket a été créé sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a créé un nouveau ticket :

\n" +"

#%(ref)s %(subject)s

\n" +" Voir le ticket\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nouveau ticket créé sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a créé un nouveau ticket :\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Voir le ticket dans %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Création du ticket #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Ticket supprimé sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a supprimé un ticket :

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Ticket supprimé sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a supprimé un ticket :\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Suppression du ticket #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" +"\n" +"

Sprint mis à jour sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a mis un jour un sprint :

\n" +"

%(name)s

\n" +" Voir le sprint\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" +"\n" +"Sprint mis à jour sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a mise un jour un sprint :\n" +"\n" +"%(name)s\n" +"\n" +"Voir le sprint ici : %(url)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Sprint mis à jour \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nouveau sprint créé sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a créé un nouveau sprint

\n" +"

%(name)s

\n" +" Voir le sprint\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nouveau sprint créé sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a créé un nouveau sprint :\n" +"\n" +"%(name)s\n" +"\n" +"Voir le sprint ici : %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Sprint \"%(milestone)s\" créé\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Sprint supprimé sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a supprimé un sprint :

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Sprint supprimé sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a supprimé un sprint :\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Sprint \"%(milestone)s\" Éffacé\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" +"\n" +"

Tâche mise à jour sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a mis à jour une tâche :

\n" +"

#%(ref)s %(subject)s

\n" +" Voir la tâche\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" +"\n" +"Tâche mise à jour sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a mis à jour la tâche :\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Voir la tâche dans %(url)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Tâche #%(ref)s \"%(subject)s\" mise à jour\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nouvelle tâche créée sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a créé une nouvelle tâche :

\n" +"

#%(ref)s %(subject)s

\n" +" Voir la tâche\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nouvelle tâche créée sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a créé une nouvelle tâche :\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Voir la tâche dans %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Tâche #%(ref)s \"%(subject)s\" créée\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Tâche supprimée dans %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a supprimé une tâche :

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Tâche supprimée dans %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a supprimé une tâche :\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Tâche #%(ref)s \"%(subject)s\" supprimée\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" +"\n" +"

Récit utilisateur mis à jour dans %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a mis à jour un récit utilisateur :" +"

\n" +"

#%(ref)s %(subject)s

\n" +" Voir le récit utilisateur\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" +"\n" +"Récit utilisateur mis à jour dans %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a mis à jour un récit utilisateur :\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Voir le récit utilisateur dans %(url)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] US #%(ref)s \"%(subject)s\" mise à jour\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nouveau récit utilisateur créé dans %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a créé un nouveau récit " +"utilisateur :

\n" +"

#%(ref)s %(subject)s

\n" +" Voir le récit utilisateur\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nouveau récit utilisateur créé dans %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a créé un nouveau récit utilisateur :\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Voir le récit utilisateur dans %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Récit Utilisateur #%(ref)s \"%(subject)s\" créé\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Récit utilisateur supprimé dans %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a supprimé un récit utilisateur :\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Récit utilisateur supprimé dans %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a supprimé un récit utilisateur\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Suppression du récit utilisateur #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" +"\n" +"

Page de Wiki mise à jour sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a mis à jour une page de Wiki :\n" +"

%(page)s

\n" +" Voir " +"la page de Wiki\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" +"\n" +"Page de Wiki mise à jour sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a mis à jour une page de Wiki :\n" +"\n" +"%(page)s\n" +"\n" +"Voir la page de Wiki ici : %(url)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Page Wiki \"%(page)s\" mise à jour\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Nouvelle page de Wiki créée sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a créé une nouvelle page de Wiki :" +"

\n" +"

%(page)s

\n" +" Accéder à la page de Wiki\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Nouvelle page de Wiki créée sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a créé une nouvelle page de Wiki :\n" +"\n" +"%(page)s\n" +"\n" +"Accédez à la page de Wiki ici : %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Page Wiki \"%(page)s créée\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Page de Wiki supprimée sur %(project)s

\n" +"

Bonjour %(user)s,
%(changer)s a supprimé une page de Wiki :

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Page de Wiki supprimée sur %(project)s\n" +"\n" +"Bonjour %(user)s, %(changer)s a supprimé une page de Wiki :\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Page Wiki \"%(page)s\" supprimée\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "La liste des observateurs contient des utilisateurs invalides" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "La version doit être un nombre entier" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "La version n'est pas valide" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "La version ne correspond pas à la version courante" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "version" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"Vous ne pouvez pas quitter le projet si vous en êtes le propriétaire ou " +"qu'il n'y a pas d'autre administrateur" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "Le jeton ne correspond à aucune invitation valide." + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "L'utilisateur n'existe pas." + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "L'utilisateur est déjà membre du projet." + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "Adresse email invalide." + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Projet sans propriétaire" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "Vous avez atteint le nombre maximum d'adhésions à des projets privés" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "Vous avez atteint le nombre maximum d'adhésions à des projets publics" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "Vous avez atteint le nombre maximum d'adhésions en attente" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "Tâche #%(ref)s" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Sprint futurs" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Fin du projet" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Jeton invalide" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Le jeton est périmé" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "Chronologie" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "Épopées" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "Backlog" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "Tickets" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "TeamWiki" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "Vous n'avez pas accès à cette partie" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" +"Mot-clé invalide '{value}'. La couleur n'est pas une couleur HEX ou nulle." + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"Balise non valide '{value}'. Il doit s'agir du nom ou d'une paire " +"'[\"name\", \"hex color/\" | null]'." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "Mot-clé '{value}' invalide. Il doit être le nom du mot-clé." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "tags" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "couleurs des tags" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Ce mot-clé existe." + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "La couleur n'est pas un code HEX valide." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "Ce mot-clé n'existe pas." + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "Vous n'avez pas la permission d'affecter ce sprint à cette tâche." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "Vous n'avez pas la permission d'affecter ce récit à cette tâche." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "Vous n'avez pas la permission d'affecter ce statut à ce problème." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "ordre des us" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "order du tableau de tâches" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "est de l'iocaine" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "Identifiant de jalon invalide." + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "Identifiant de statut de tâche invalide." + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "Identifiant de Récit utilisateur invalide." + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" +"Identifiant de statut de tâche invalide. Le statut doit appartenir au même " +"projet." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" +"Identifiant de Récit utilisateur invalide. Le Récit utilisateur doit " +"appartenir au même projet." + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" +"Identifiant de jalon invalide. le jalon doit appartenir au même projet." + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" +"Identifiants de tâche invalides. Toutes les tâches doivent appartenir au " +"même projet, et le cas échéant au même récit et/ou jalon." + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "Toutes les tâches doivent être dans le même projet" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "quelqu'un" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" +"\n" +"

Vous avez été invité à %(product_name)s!

\n" +"

Bonjour ! %(full_name)s vous a envoyé un invitation pour rejoindre le " +"projet %(project)s dans %(product_name)s.
Taiga est un outil " +"Open Source de gestion de projet agile. Il n'y a aucun frais pour vous à " +"être un utilisateur de Taiga.

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

Un petit mot de la part de celui ou celle qui vous a invité

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Acceptez votre invitation à Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Accepter votre invitation" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" +"\n" +"Vous, ou quelqu'un que vous connaissez, vous a invité sur %(product_name)s\n" +"\n" +"Bonjour ! %(full_name)s vous a envoyé une invitation à rejoindre un projet " +"nommé %(project)s sur %(product_name)s.\n" +"Taïga est un outil de gestion de projet Libre. Il n'y aucun coût pour vous à " +"utiliser Taïga.\n" +"\n" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"Un petit mot de la part de celui ou celle qui vous a invité :\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Acceptez votre invitation à Taiga en cliquant sur ce lien :" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Invitation à rejoindre le projet '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Vous avez été ajouté à un projet

\n" +"

Bonjour %(full_name)s,
vous avez été ajouté au projet " +"%(project)s

\n" +" Aller au " +"projet\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Vous avez été ajouté à un projet\n" +"\n" +"Bonjour %(full_name)s, vous avez été ajouté au projet %(project)s\n" +"\n" +"Voir le projet dans %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Ajouté au projet '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Bonjour %(old_owner_name)s,

\n" +"

%(new_owner_name)s a accepté votre offre et devient le nouveau " +"propriétaire du projet \"%(project_name)s\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s a dit :

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

À partir de maintenant, votre nouveau status pour ce projet sera celui " +"d'Administrateur.

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"Bonjour %(old_owner_name)s,\n" +"%(new_owner_name)s a accepté votre offre et devient le nouveau propriétaire " +"du projet \"%(project_name)s\".\n" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s dit :" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"À partir de maintenant, votre nouveau status pour ce projet sera celui " +"d'Administrateur.\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] Offre de transfert de propriété du projet acceptée !\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Bonjour %(owner_name)s,

\n" +"

%(rejecter_name)s a décliné votre offre et ne deviendra pas le " +"nouveau propriétaire du projet \"%(project_name)s\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s a dit :

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

Vous pouvez toujours, si vous le désirez, essayer de transférer la " +"propriété du projet à quelqu'un d'autre.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "Demande de transfert à une autre personne" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"Bonjour %(owner_name)s,\n" +"%(rejecter_name)s a refusé votre proposition de devenir le nouveau porteur " +"du projet \"%(project_name)s\".\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s dit :" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"Si vous le souhaitez, vous pouvez toujours essayer de transférer la " +"propriété du projet à une autre personne.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "Demander un transfert à une autre personne :" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] Offre de transfert de propriété du projet déclinée\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Bonjour %(owner_name)s,

\n" +"

%(requester_name)s a demandé à devenir le porteur de projet de " +"\"%(project_name)s\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

Veuillez cliquer sur \"Continuer\" si vous souhaitez lancer le " +"transfert du projet à partir du panneau d'administration.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Continuer" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" +"\n" +"Bonjour %(owner_name)s,\n" +"%(requester_name)s a demandé à ce que vous deveniez le nouveau porteur de " +"projet pour \"%(project_name)s\".\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"Veuillez vous rendre dans les paramètres de votre projet si vous souhaitez " +"lancer le transfert du projet à partir du panneau d'administration.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Allez dans les paramètres de votre projet :" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s] Demande de transfert de propriété du projet\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

Bonjour %(receiver_name)s,

\n" +"

%(owner_name)s, l'actuel porteur de projet pour " +"\"%(project_name)s\" souhaiterait que vous le remplaciez.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s a dit :

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

Veuillez cliquer sur \"Continuer\" pour accepter ou rejeter cette " +"proposition.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"Bonjour %(receiver_name)s,\n" +"%(owner_name)s, l'actuel porteur de projet pour \"%(project_name)s\" " +"souhaiterait que vous le remplaciez.\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s dit :" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"Veuillez cliquer sur le lien suivant pour accepter ou rejeter cette " +"proposition.

\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "Acceptez ou refusez le transfert de propriété du projet :" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s] Demande de transfert de propriété du projet\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Dans la méthodologie Scrum, le backlog produit est une liste de " +"fonctionnalités priorisées, contenant de brèves descriptions de toutes les " +"fonctionnalités voulues pour le produit. L'application de Scrum ne nécessite " +"pas de démarrer un projet par un effort long et exhaustif de documentation " +"des exigences. Le backlog produit peut s'allonger ou même changer au fur et " +"à mesure que l'équipe projet apprend sur le produit et sur ses utilisateurs" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban est une méthode de gestion du travail qui met l'accent sur la " +"réalisation \"juste à temps\", tout en ne surchargeant pas les membres de " +"l'équipe. Dans cette approche, le processus, depuis la définition d'une " +"tâche jusqu'à sa livraison au client, est mis à disposition des participants " +"qui peuvent le consulter et y puiser leur travail." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Nouveau" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Prêt" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "En cours" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Prêt à tester" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Fait" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Archivé" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Fermé" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Infos manquantes" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Repoussé" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Rejeté" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Bug" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Question" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Amélioration" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Faible" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normal" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Fort" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Souhaits" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Mineur" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Important" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Critique" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "Expérience utilisateur" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Design" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Front" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Back" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Product Owner" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Participant" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"Vous n'avez pas la permission d'affecter ce sprint à ce récit utilisateur." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" +"Vous n'avez pas la permission d'affecter ce statut à ce récit utilisateur." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" +"Vous n'avez pas les autorisations nécessaires pour associer ce couloir à ce " +"récit utilisateur." + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "Identifiant de rôle non valide '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "Identifiant de points invalide '{points_id}'" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Génération du récit utilisateur #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "rôle" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "order du backlog" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "ordre du sprint" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "Classement du Kanban" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "date de fin" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "Utilisateurs affectés" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "généré depuis un problème" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "Généré à partir de la tâche" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "Référencé dans la tâche" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "couloir" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Il n'y a pas de récit utilisateur avec cet id" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"Identifiant de récit utilisateur invalide. Le statut doit appartenir au même " +"projet." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" +"Identifiant de couloir invalide. Le couloir doit appartenir au même projet." + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"L'identifiant du récit utilisateur à déplacer est invalide. Le récit " +"utilisateur doit appartenir au même projet et jalon." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "Vous ne pouvez pas définir un avant et un après en même temps." + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"L'identifiant du récit utilisateur à déplacer est invalide. Le récit " +"utilisateur doit appartenir au même projet et jalon." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" +"Identifiants de récits utilisateur invalides. Tous les récits doivent " +"appartenir au même projet." + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"L'identifiant du récit utilisateur à déplacer est invalide. Le récit " +"utilisateur doit appartenir au même projet, au même statut et au même " +"couloir." + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"L'identifiant du récit utilisateur à déplacer est invalide. Le récit " +"utilisateur doit appartenir au même projet, statut et couloir." + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Aucun projet avec cet identifiant" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "L'utilisateur existe encore dans le projet" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "Opération non valide" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Rôle non valide pour le projet" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "L'utilisateur doit être un contact valide" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "Le propriétaire du projet doit être un administrateur." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" +"Au moins un utilisateur doit être un administrateur actif de ce projet." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" +"Identifiants de rôles invalides. Tout les rôles doivent appartenir au mêmes " +"projet." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Options par défaut" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "États du récit utilisateur" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Points" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Etats des tâches" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Statuts des problèmes" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Types de problèmes" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Priorités" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Sévérités" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Rôles" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Votes" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "vote" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content' paramètre obligatoire" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id' paramètre obligatoire" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "dernier modificateur" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "identifiant de l'instance" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Contrôlez l'historique de l'API pour les voir les différences exactes" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Membre du projet" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Membres du projet" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Propriété du projet" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Propriétés du projet" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "Adhésions" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "INFORMATIONS PERSONNELLES" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "INFORMATIONS SUPPLÉMENTAIRES" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "AUTORISATIONS" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "DATES IMPORTANTES" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "RESTRICTIONS SUR LES PROPRIÉTÉS DU PROJET" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "STATISTIQUES DE PROPRIÉTÉ DES PROJETS" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "Mes projets privés" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "Mes adhésions privées" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "Mes projets publics" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "Mes adhésions publiques" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Email dupliquée" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "Courriel non valide" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Nom d'utilisateur ou email non valide" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "Le courrier a été envoyé avec succès !" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Paramètre 'mot de passe actuel' requis" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Paramètre 'nouveau mot de passe' requis" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" +"Longueur du mot de passe invalide : au moins 6 caractères sont nécessaires" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Mot de passe actuel incorrect" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Invalide, êtes-vous sûre que le jeton est correct et qu'il n'a pas déjà été " +"utilisé ?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Invalide, êtes-vous sûre que le jeton est correct ?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "Adresse e-mail déjà vérifiée" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "Impossible de vérifier cette adresse email" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "Mail envoyé avec succès !" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "statut superutilisateur" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Indique que l'utilisateur a toutes les permissions sans avoir à lui les " +"donner explicitement." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "nom d'utilisateur" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" +"Obligatoire. 30 caractères maximum. Lettres, nombres et les caractères /./-/_" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Entrez un nom d'utilisateur valide." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "actif" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Indique qu'un utilisateur est considéré ou non comme actif. Désélectionnez " +"cette option au lieu de supprimer le compte utilisateur." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "Membre du personnel" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" +"Indique si l'utilisateur peut se connecter à la section d'administration." + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biographie" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "photo" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "date d'inscription" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "date annulée" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "conditions acceptées" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "a lu les nouvelles conditions" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "langage par défaut" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "thème par défaut" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "Fuseau horaire par défaut" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "changer la couleur des tags" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "jeton email" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "nouvelle adresse email" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "Nombre maximal de projets privés" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "Nombre maximal de projets publics" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" +"Nombre maximum d'adhésions de différents utilisateurs pour tous les projets " +"privés que vous possédez" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" +"Nombre maximum d'adhésions de différents utilisateurs pour tous les projets " +"publics que vous possédez" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "permissions" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Aucun utilisateur avec ce nom ou ce mot de passe." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Changer votre adresse de courriel

\n" +"

Bonjour %(full_name)s,
veuillez confirmer votre adresse de " +"courriel

\n" +" Confirmer mon adresse de courriel\n" +"

Vous pouvez ignorer ce message si cette demande n'est pas de votre " +"fait.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(full_name)s, veuillez confirmer votre adresse de courriel :\n" +"\n" +"%(url)s\n" +"\n" +"Vous pouvez ignorer ce message si cette demande n'est pas de votre fait..\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Email modifiée" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Réinitialiser votre mot de passe

\n" +"

Bonjour %(full_name)s,
vous avez demandé à réinitialiser votre " +"mot de passe

\n" +" Réinitialiser votre mot de passe\n" +"

Vous pouvez ignorer ce message si cette demande n'est pas de votre " +"fait.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(full_name)s, vous avez demandé à réinitialiser votre mot de " +"passe :\n" +"\n" +"%(url)s\n" +"\n" +"Vous pouvez ignorer ce message si cette demande n'est pas de votre fait.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Récupération du mot de passe" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" +"\n" +"

Veuillez confirmer votre adresse de courriel

\n" +" Confirmer mon adresse de courriel\n" +" " + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" +"\n" +"

Merci pour votre inscription à %(product_name)s

\n" +"

Nous sommes ravis que vous ayez rejoint notre communauté " +"croissante de professionnels qui révolutionnent leur façon de travailler et " +"nous espérons que vous l'apprécierez.

\n" +"

Vous avez une question ? Donnez-nous un coup de pouce si " +"jamais vous avez besoin d'un coup de main, écrivez-nous à %(support_email)s." +"

\n" +"

%(signature)s

\n" +" \n" +" " + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" Vous pouvez supprimer votre compte de ce service en " +"cliquant ici\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" +"\n" +"Merci pour votre inscription à %(product_name)s\n" +"\n" +"Nous sommes ravis que vous ayez rejoint notre communauté croissante de " +"professionnels qui révolutionnent leur façon de travailler et nous espérons " +"que vous l'apprécierez.\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" +"\n" +"Veuillez confirmer votre adresse de courriel : %(url)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" +"\n" +"Vous avez une question ? Donnez-nous un coup de pouce si vous avez besoin " +"d'un coup de main, écrivez-nous à %(support_email)s.\n" +"--\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Vous pouvez supprimer votre compte de ce service : %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Vous avez été Taigatisé !" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Vérifiez votre adresse de courriel

\n" +"

Bonjour %(full_name)s,
veuillez confirmer votre adresse de " +"courriel

\n" +" Confirmer mon adresse de courriel\n" +"

Vous pouvez ignorer ce message si cette demande n'est pas de votre " +"fait.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bonjour %(full_name)s, veuillez vérifier votre adresse de courriel :\n" +"\n" +"%(url)s\n" +"\n" +"Vous pouvez ignorer ce message si cette demande n'est pas de votre fait.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "[Taïga] Confirmez votre adresse de courriel" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "invalide" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Nom d'utilisateur invalide. Essayez avec un autre nom." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "A lu les nouvelles conditions doit être vrai" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "Violation de clé primaire. La clé '{}' existe déjà." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "clé" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "clé secrète" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "code retour" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "données de la requête" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "en-têtes de la requête" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "données de la réponse" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "en-têtes de la réponse" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "durée" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "Adresse IP non autorisée" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "" +#~ "Identifiant de jalon invalide. Le jalon doit appartenir au même projet." + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "" +#~ "Identifiants de récit utilisateur invalides. Tous les récits doivent " +#~ "appartenir au même projet et le cas échéant au même statut / jalon." + +#~ msgid "Personal info" +#~ msgstr "Informations personnelles" + +#~ msgid "Permissions" +#~ msgstr "Permissions" + +#~ msgid "Restrictions" +#~ msgstr "Restrictions" + +#~ msgid "Important dates" +#~ msgstr "Dates importantes" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/he/LC_MESSAGES/django.po b/taiga/locale/he/LC_MESSAGES/django.po new file mode 100644 index 000000000..36dfaca95 --- /dev/null +++ b/taiga/locale/he/LC_MESSAGES/django.po @@ -0,0 +1,4797 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# cap awsomeee , 2019 +# chananel buchman , 2018 +# DORAD SOFT , 2016 +# lotan , 2016 +# lotan , 2016 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-02-26 11:31+0000\n" +"Last-Translator: Taiga Dev Team \n" +"Language-Team: Hebrew (http://www.transifex.com/taiga-agile-llc/taiga-back/" +"language/he/)\n" +"Language: he\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n == 2 && n % " +"1 == 0) ? 1: (n % 10 == 0 && n % 1 == 0 && n > 10) ? 2 : 3;\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "סוג התחברות לא תקין" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "לא נמצא" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "שם משתמש לא תקין" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "נדרש. 255 תווים או פחות. אותיות, מספרים ו /./-/_ תווים'" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "שם משתמש כבר בשימוש." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "דוא\"ל כבר בשימוש." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "משתמש כבר רשום." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "שדה זה נדרש." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "ערך לא תקין." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "ערך '%s' חייב להיות או אמת או שקר." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "הזן כתובת תקפה, שמורכבת מאותיות, מספרים ומקפים בלבד" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "בחר/י ברירה תקפה. %(value)s אינה ברשימת הברירות האפשריות." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "שירות הדוא״ל שהזנת חסומה " + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "הכניסו כתובת דוא\"ל חוקית" + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "פורמט התאריך שגויה. השתמש/י באחת התבניות תאריך האלה: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "חותמת הזמן בפורמט לא קביל, השתמש/י באחת הפורמטים הבאים: %s " + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "זמן בפורמט לא קביל. השתמש/י באחת הפורמטים האלה: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "הכניסו מספר שלם" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "הכניסו מספר" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "" + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "" + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "לא נשלח כל קובץ" + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "הקובץ שנשלח הוא ריק" + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "אלמנט חסום" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "א=העמוד אינו אחרון והוא אינו יכול להפוך למספר" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "הגדרת הרשאה בלתי חוקית" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "קישור בלתי חוקי - אין כתובת מתאימה" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "נתונים לא תקינים" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "לא הוזן קלט" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "נדרשת רשימת פריטים" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "לא נמצא" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "הרשאה נדחיתה" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "תקלת שרת ביישום" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "שגיאת חיבור" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "טאיגה" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "עדכונים" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Sprint נוצר" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Sprint שונה" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Sprint נמחק" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Sprint: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "שגיאה ביבוא התפקידים" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "תקלה בייבוא Sprint" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "שם" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "תיאור" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "אסימון לא תקין" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "הצג סיפורי משתמש" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "הצג משימות" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "הצג תקלות" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "הצג דפי ויקי" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "הצג קישורי ויקי" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "מחק תקלה" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "מחק פרויקט" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "שדה זה נדרש." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "פרוייקט" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "הוצא מכלל שימוש" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "סדר" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "מותאם אישית" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "כיתוב" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "תאריך" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "כתובת" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "טיפוס" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "סיפור משתמש" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "סטטוס" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "נושא" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "משוייך ל" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "שינוי" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "צור" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "מחק" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "מאת" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "אל" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "הוסף" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "הוסר" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "לא משוייך" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "תוכן" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "הערה חסומה" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "Sprint" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "חומרה" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "עדיפות" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "אהבתי" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "'אהבתי'ים" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "סגורה" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "חסום" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "נקודות" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "רק מעורבויות" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "הכל" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "כלום" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "במעקב" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "המשתמש הוא כבר חבר בפרוייקט." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "Enter a valid email address." +msgid "Malformed email adress." +msgstr "הכניסו כתובת דוא\"ל חוקית" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "קאנבאן" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "תגיות" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "סדר סיפורי המשתמש" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "סדר לוח המשימות" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "דורש מקמול?" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "סקראם" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "חדש" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "הושלם" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "סגור" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "הקודם" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "סדר העתודה" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "סדר הספרינטים" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "תאריך סיום" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "נקודות" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "עדיפויות" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "חומרות" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "תפקידים" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "הצבעות" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "כתובת" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Permissions" +#~ msgstr "הרשאות" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/hu/LC_MESSAGES/django.po b/taiga/locale/hu/LC_MESSAGES/django.po new file mode 100644 index 000000000..d8caa1a1c --- /dev/null +++ b/taiga/locale/hu/LC_MESSAGES/django.po @@ -0,0 +1,4691 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2024-05-21 08:28+0000\n" +"Last-Translator: Huba Gáspár \n" +"Language-Team: Hungarian \n" +"Language: hu\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.6-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "érvénytelen bejelentkezési típus" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "" + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "" + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "" + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "" + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "" + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "" + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "" + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "" + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "" + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "" + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "" + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "" + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" diff --git a/taiga/locale/id/LC_MESSAGES/django.po b/taiga/locale/id/LC_MESSAGES/django.po new file mode 100644 index 000000000..4ef5aa16d --- /dev/null +++ b/taiga/locale/id/LC_MESSAGES/django.po @@ -0,0 +1,4718 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-08 12:10+0000\n" +"PO-Revision-Date: 2024-03-02 13:02+0000\n" +"Last-Translator: Reza Almanda \n" +"Language-Team: Indonesian \n" +"Language: id\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 5.5-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "jenis login tidak valid" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "Pendaftaran publik dinonaktifkan." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "Anda harus menerima syarat layanan dan kebijakan privasi kami" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "jenis pendaftaran tidak valid" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "Header otorisasi harus berisi dua nilai yang dibatasi spasi" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "Token yang diberikan tidak berlaku untuk semua jenis token" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "Token tidak berisi identifikasi pengguna yang dapat dikenali" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "Pengguna tidak ditemukan" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "Pengguna tidak aktif" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "Jenis algoritme yang tidak dikenali '{}'" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "Anda harus memiliki kriptografi yang terinstal untuk menggunakan {}." + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "Algoritma yang ditentukan tidak valid" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "Token salah atau kedaluarsa" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "Nama pengguna tidak valid" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Diperlukan. 255 karakter atau kurang. Huruf, angka dan /./-/_ karakter'" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Nama lengkap tidak valid" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "Tidak ditemukan akun aktif dengan kredensial yang diberikan" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Nama pengguna telah digunakan." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Email sudah digunakan." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Pengguna sudah terdaftar." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Error saat membuat pengguna baru." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "jti" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "pengguna" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "dibuat di" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "berakhir pada" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "Penolakan Token" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "Tidak dapat membuat token tanpa jenis atau masa pakai" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "Token tidak memiliki id" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "Token tidak memiliki jenis" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "Token memiliki jenis yang salah" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "Token tidak memiliki klaim '{}'" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "Klaim Token '{}' telah kedaluwarsa" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "Token ditolak" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Bagian ini harap diisi." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Nilai tidak valid." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "" + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "" + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "" + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "" + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "" + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "" + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Tugas baru dibuat pada %(project)s

\n" +"

Halo %(user)s,
%(changer)s telah membuat tugas baru:

\n" +"

#%(ref)s %(subject)s

\n" +" Lihat tugas\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Membuat tugas #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Tugas dihapus pada %(project)s

\n" +"

Halo %(user)s,
%(changer)s telah menghapus tugas:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Tugas dihapus pada %(project)s\n" +"\n" +"Halo %(user)s, %(changer)s telah menghapus tugas:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Menghapus tugas #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "" + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" diff --git a/taiga/locale/it/LC_MESSAGES/django.po b/taiga/locale/it/LC_MESSAGES/django.po new file mode 100644 index 000000000..f731d781b --- /dev/null +++ b/taiga/locale/it/LC_MESSAGES/django.po @@ -0,0 +1,5127 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Alberto Gloder , 2016 +# Andrea Raimondi , 2015 +# David Barragán , 2015 +# F B , 2016 +# lorenzo farnararo , 2018 +# luca corsato , 2015 +# Marco Somma , 2015 +# Marco Vito Moscaritolo , 2015 +# Roberto Bizzozero , 2017-2018 +# Vittorio Della Rossa , 2015 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2023-09-10 09:21+0000\n" +"Last-Translator: Random \n" +"Language-Team: Italian \n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.0.1-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "Tipo di login non valido" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "Le registrazioni pubbliche sono disattivate." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" +"Devi accettare i nostri termini di servizio e l'informativa sulla privacy" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "tipo di registrazione non valido" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "Non trovato" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "Ruolo di progetto non valido" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "Token non valido" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "Nome utente non valido" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" +"Obbligatorio. Al massimo 255 caratteri. Lettere, numeri e caratteri /./-/_" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Nome completo non valido" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Il nome utente scelto è già utilizzato." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "L'email inserita è già utilizzata." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "L'Utente è già registrato." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Errore di creazione del nuovo utente." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "utente" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "Token non valido" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no id" +msgstr "Il token e' scaduto" + +#: taiga/auth/tokens.py:138 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no type" +msgstr "Il token e' scaduto" + +#: taiga/auth/tokens.py:141 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has wrong type" +msgstr "Il token e' scaduto" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +#, fuzzy +#| msgid "Token has expired" +msgid "Token '{}' claim has expired" +msgstr "Il token e' scaduto" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Token non valido" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Questo campo è obbligatorio." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Valore non valido." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "il valore di '%s' deve essere o Vero o Falso." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Uno 'slug' valido è composto da lettere, numeri, caratteri di sottolineatura " +"o trattini." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"Seleziona una scelta valida. %(value)s non è fra le scelte disponibili." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Il dominio della tua email non e' permesso" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Inserisci un indirizzo e-mail valido." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "La data non ha un formato valido. Usa uno dei formati disponibili: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "L'orario non ha un formato valido. Usa uno dei formati disponibili: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "Formato temporale errato. Usa uno dei seguenti formati: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Inserisci il numero completo." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Assicurati che questo valore sia minore o uguale di %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Assicurati che questo valore sia maggiore o uguale di %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "il valore \"%s\" deve essere un valore \"float\"." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Inserisci un numero." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Assicurati che non ci siano più di %s cifre in totale." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Assicurati che non ci siano più di %s decimali." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Assicurati che non ci siano più di %s cifre prima del punto decimale." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" +"Non è stato caricato alcun file. Controlla il tipo di codifica nella scheda." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Nessun file caricato." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Il file caricato è vuoto." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Assicurati che il nome del file abbia al massimo %(max)d caratteri (ne ha " +"%(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" +"Carica un file oppure controlla la casella deselezionata, non entrambi." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Carica un'immagina valida. Il file che hai caricato non era un'immagine " +"oppure era corrotta." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Elemento bloccato" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "La pagina non è 'last', né può essere convertita come int." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Pagina (%(page_number)s) non valida: %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Definizione di permesso non valida." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "pk '%s' non valido - l'oggetto non esiste." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Inserimento scorretto. Atteso un valore pk, ricevuto %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "L'oggetto con %s=%s non esiste." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Hyperlink invalido - nessun URL abbinato" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Hyperlink invalido - l'URL abbinato non è corretto" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "URL invalido a causa di un errore di configurazione" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Collegamento ipertestuale non valido - l'oggetto non esiste." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Inserimento scorretto. Attesa una stringa con URL, ricevuto %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Dati non validi" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Non è stato fornito nessun input" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Impossibile creare un nuovo elemento, solo quelli esistenti possono essere " +"aggiornati." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Ci si aspetta una lista di oggetti." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Non trovato" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Permesso negato" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Errore sul server" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Errore di connessione." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Richiesta composta erroneamente." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Le credenziali non sono corrette." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Le credenziali per l'autenticazione non sono state fornite." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Non hai il permesso per eseguire l'azione." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Metodo '%s' non permesso." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Non è possibile soddisfare l'header Accept della richiesta" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Nella richiesta è presente un contenuto media '%s' non supportato." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "La richiesta è stata soppressa." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Disponibile in %d secondi%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Errore inaspettato" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Non trovato." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Metodo non supportato dall'endpoint." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Argomento errato." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Errore di validazione dei dati" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Errore di integrità causato da un argomento invalido o sbagliato" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Errore di precondizione" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Non c'e' piu' spazio per altri progetti." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s non è una lista" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Errore nel filtro del tipo di parametri." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'Progetto' deve essere un valore intero." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Sei stato Taigatizzato" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

Benvenuti in " +"%(product_name)s, uno strumento Open Source e Agile di gestione progetti\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" Configura le notifiche email " +"o disiscriviti\n" +"  • \n" +" Supporto di Taiga\n" +"  • \n" +" Contattaci\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Seguici su Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Prendi il codice su GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Aggiornamenti" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Aggiornamenti" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

commento:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Commento: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Errore accesso host" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "Errore indirizzo IP" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "Storia utente creata" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "Storia utente modificata" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "Storia utente eliminata" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "SU #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "C'è bisogno di almeno un ruolo" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "E' richiesto un file di dump" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Formato di dump invalido" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "Errore nell'importazione del progetto dati" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "Errore nell'importazione i ruoli" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "Errore nell'importazione delle iscrizioni" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "Errore nell'importazione della lista degli attributi di progetto" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "Errore nell'importazione degli attributi personalizzati" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "errore nell'importazione degli sprints" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "errore nell'importazione dei problemi" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "Errore nell'importazione delle user story" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "errore di importazione degli epici" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "errore nell'importazione dei compiti" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "Errore nell'importazione delle pagine wiki" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "Errore nell'importazione dei link di wiki" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "Errore nell'importazione dei tags" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "Errore nell'importazione delle timelines" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "si e' verificato un errore inaspettato importando il progetto" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Non puoi avere altri progetti privati" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" +"Questo progetto arriva al tuo limite attuale di iscrizioni per progetti " +"privati" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Non puoi avere altri progetti pubblici" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" +"Questo progetto arriva al tuo limite attuale di iscrizioni per progetti " +"pubblici" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Errore nella creazione del dump di progetto" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"Errore di caricamento del dump da {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"MOTIVO:\n" +"-------\n" +"{reason}\n" +"\n" +"DETTAGLI:\n" +"--------\n" +"{details}\n" +"\n" +"ERRORE TRACE:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Errore nel caricamento del dump di progetto" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Errore nel caricamento del dump di progetto" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr " -- nessuna informazione --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] Il dump del tuo progetto è stato creato correttamente" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Il dump del tuo progetto è stato importato" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" non è stato trovato in questo progetto" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Contenuto errato. Deve essere {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "E' richiesta l'autenticazione" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "nome" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "Url dell'icona" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "descrizione" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Url successivo" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "applicazione" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Token non valido" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "Nome completo" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "indirizzo email" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "Commento" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "data creata" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Feedback

\n" +"

Taiga ha ricevuto opinioni da %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Commento

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Informazioni aggiuntive" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- Da: %(full_name)s <%(email)s>\n" +"---------\n" +"- Commento:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Maggiori informazioni:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Feedback da %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Il progetto non esiste" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Firma non valida" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"Commento Da {platform}:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Commento sul problema non valido" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Problema creato da {platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Informazione sul problema non valida" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "utente sconosciuto" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} ha cambiato lo stato da [{platform} commit]({commit_url} \"Vedi " +"il commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Stato: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"Cambiato lo stato dal commit {platform}.\n" +"\n" +"- Stato: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"Questo {type_name} è stato menzionato da {user_text} in [{platform} commit]" +"({commit_url} \"Vedi la commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" +"Questo problema e' stato citato nel commit \"{commit_message}\" di {platform}" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "L'elemento di riferimento non esiste" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Lo stato non esiste" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Il parametro progetto è richiesto" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Richiesta API Asana non valida" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "La richiesta inviata alle API Asana non è andata a buon fine" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Il parametro codice è richiesto" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Errore nell'importare il progetto Asana" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Dati di autenticazione non validi" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "Il servizio esterno non sta funzionando" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "Errore nell'importare il progetto GitHub" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "Il parametro url è richiesto" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "project_type {} non valido" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "Configurazione del server Jira non valida." + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "Token di autorizzazione scaduto o non valido" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Errore nell'importare il progetto Jira" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "Errore nell'importare il progetto PivotalTracker" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Il tuo progetto Asana è stato importato" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] Il tuo progetto GitHub è stato importato" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Il tuo progetto Jira è stato importato" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Il tuo progetto Trello è stato importato" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "Richiesta non valida: %(text)s su %(url)s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "Non autorizzato: %(text)s su %(url)s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "Risorsa non raggiungibile: %(text)s su %(url)s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "Errore nell'importare il progetto Trello" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Vedi progetto" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Guarda le milestones" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Vedi epico" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Guarda le storie utente" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Guarda i compiti" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Guarda i problemi" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Guarda le pagine wiki" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Guarda i lik di wiki" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Aggiungi una tappa" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Modifica la tappa" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Elimina la tappa" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Aggiungi epico" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Modifica epico" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "Commenta epico" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Eliminca epico" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Guarda la storia utente" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Aggiungi una storia utente" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Modifica una storia utente" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "Commenta la storia dell'utente" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Cancella una storia utente" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Aggiungi un compito" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Modifica il compito" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Commenta compito" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Elimina compito" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Aggiungi un problema" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Modifica il problema" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "Commenta il problema" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Elimina il problema" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Aggiungi una pagina wiki" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Modifica la pagina wiki" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Commenta la pagina wiki" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Elimina la pagina wiki" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Aggiungi un link wiki" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Modifica il link di wiki" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Elimina la pagina wiki" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Modifica il progetto" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Elimina il progetto" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Aggiungi un membro" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Rimuovi il membro" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Valori dell'amministratore del progetto" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Ruoli dell'amministratore" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "Moduli" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Valori predefiniti" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "Attività" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "Sostenitori" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "proprietario" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Rendi pubblico" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} resi pubblici con successo." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Rendi privato" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} resi privati con successo." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Argomento non valido" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Formato dell'immagine non valido" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "id utente non valido" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "L'utente non esiste" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "comment is required" +msgid "Email is required" +msgstr "commento richiesto" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"Il progetto deve avere un proprietario ed almeno uno dei suoi utenti deve " +"essere un amministratore attivo" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "Aggiornamento non parziale non supportato" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "progetto" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "tipo di contenuto" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "ID dell'oggetto" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "data modificata" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "file allegato" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "è deprecato" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "dal commento" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "ordine" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +#, fuzzy +#| msgid "" +#| "Invalid task ids. All tasks must belong to the same project and, if it " +#| "exists, to the same status, user story and/or milestone." +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" +"id stati attivita' non validi. Tutte le attivita' devono appartenere allo " +"stesso progetto e, se esiste, allo stesso stato, storia utente e/o milestone." + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Personalizzato" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "Questo progetto e' bloccato a causa di un falliimento nel pagamento" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "Questo progetto e' bloccato dallo staff di amministrazione" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "Questo progetto e' bloccato perche' il proprietario lo ha abbandonato" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "Questo progetto e' bloccato finche' e' cancellato" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +" %(full_name)s ha " +"scritto a %(project_name)s\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +" Ricevi questo messaggio perché sei indicato come amministratore del " +"progetto chiamato %(project_name)s. Se non vuoi che membri della comunità di " +"Taiga possano contattarti in merito al progetto, per favore aggiorna le impostazioni del tuo progetto per evitare queste richieste. Le regolari comunicazioni tra i membri del " +"progetto non saranno modificate.\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s ha scritto a %(project_name)s\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +"Ricevi questo messaggio perché sei indicato come amministratore del progetto " +"dal titolo %(project_name)s. Se non vuoi che membri della comunità di Taiga " +"possano contattarti in merito al progetto, per favore aggiorna le " +"impostazioni del tuo progetto a %(project_settings_url)s per evitare queste " +"richieste di contatto. Le regolari comunicazioni tra i membri del progetto " +"non saranno modificate.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s ha mandato un messaggio al progetto %(project_name)s\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Testo" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Testo multi-linea" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "Rich text" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Data" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "tipo" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "valori" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "epico" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "storia utente" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "compito" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "problema" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Ne esiste già un altro con lo stesso nome." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "Non hai i permessi per impostare lo stato a questo epico." + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "referenza" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "stato" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "ordine epici" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "soggeto" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "colore" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "assegnato a" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "è un requisito del cliente" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "é una richiesta del team" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "storie utente" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "referenza esterna" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "Non ci sono epici con questo id" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "commento richiesto" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "i commenti cancellati non possono essere modificati" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Il commento è già stato eliminato" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Commento non eliminato" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Cambiato" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Creato" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Eliminato" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s punti del ruolo" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "da" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "a" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Aggiunto un nuovo allegato" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Allegato aggiornato" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "deprecato" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "accettato" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Allegato eliminato" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "aggiunto" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "rimosso" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Non assegnato" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-eliminato-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "a:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "da:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Aggiunto" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Modificato" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Eliminato" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "aggiunto:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "rimosso:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Da:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "A:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "contenuto" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "nota bloccata" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "sprint" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Non hai i permessi per aggiungere questo sprint a questo problema." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Non hai i permessi per aggiungere questo stato a questo problema." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "Non hai i permessi per aggiungere questa criticità a questo problema." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "Non hai i permessi per aggiungere questa priorità a questo problema." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Non hai i permessi per aggiungere questa tipologia a questo problema." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "criticità" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "priorità" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "tappa" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "data di conclusione" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "La milestone non è valida per il progetto" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Mi piace" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Piaciuto" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "lumaca" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "data stimata di inizio" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "data stimata di fine" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "è concluso" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "disponibilità" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" +"La data stimata di inizio deve essere precedente alla data stimata di fine." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "Non ci sono milestone con questo id" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "Tutte le storie utente devono provenire dallo stesso progetto" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "è bloccato" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "parametro ref e' richiesto" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "I parametri project oppure project__slug sono richiesti" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "il parametro '{param}' è obbligatorio" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "il parametro 'project' è obbligatorio" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "L'utente deve essere un membro del progetto." + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "email" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "creato il" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "token" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "testo ulteriore per l'invito" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "ordine dell'utente" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "L'utente è già membro del progetto" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "stato predefinito dell'epico" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "stati predefiniti per le storie utente" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "punti predefiniti" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "stati predefiniti del compito" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "priorità predefinita" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "criticità predefinita" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "stato predefinito del problema" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "tipologia predefinita del problema" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "logo" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "membri" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "tappe totali" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "punti totali della storia" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "contatto attivo" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "pannelli epici attivi" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "pannello di backlog attivo" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "pannello kanban attivo" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "pannello wiki attivo" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "pannello dei problemi attivo" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "sistema di videoconferenza" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "ulteriori dati di videoconferenza" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "creazione del template" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "è privato" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "permessi anonimi" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "permessi dell'utente" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "in vetrina" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "sta cercando persone" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "ricerca avvisi" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "token di trasferimento progetto" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "codice bloccato" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "tempo e data aggiornati" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "conta" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "fans nella settimana" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "fans nel mese" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "fans nell'anno" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "attività nella settimana" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "attività nel mese" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "attività nell'anno" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "configurazione dei moduli" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "è archivitato" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "limite dei lavori in corso" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "valore" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "ruolo proprietario predefinito" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "opzioni predefinite" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "stati dell'epico" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "stati della storia utente" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "punti" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "stati del compito" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "stati del probema" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "tipologie del problema" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "priorità" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "criticità" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "ruoli" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "attributi personalizzati dell'epico" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "attributi personalizzati della storia utente" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "attributi personalizzati del compito" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "attributi personalizzati della segnalazione" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Coinvolto" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Tutti" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Nessuno" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "tempo e data creati" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "inserimenti della storia" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "notifica utenti" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Osservato" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "La notifica esiste per l'utente e il progetto specificati" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Valore non valido per il livello di notifica" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"
See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"Epico aggiornato\n" +"Ciao %(user)s, %(changer)s ha aggiornato un epico su %(project)s\n" +"Vedi epico #%(ref)s %(subject)s in %(url)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Aggiornato l'epico #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Ha creato l'epico #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Ha cancellato l'epico #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Aggiornato il problema #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creato il problema #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Eliminato il problema #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Aggiornato lo sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creato lo sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Eliminato lo sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Aggiornato il compito #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creato il compito #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Eliminato il compito #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Aggiornata la storia utente #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creata la storia utente #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Eliminata la storia utente #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Aggiornata la pagina wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Creata la pagina wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Eliminata la pagina wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "L'osservatore contiene un utente non valido" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "La versione deve essere un intero" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "Il parametro della versione non è valido" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "La versione non corrisponde a quella corrente" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "versione" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"Non puoi lasciare il progetto se sei il proprietario o se non ci sono piu' " +"amministratori" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "Il token non corrisponde ad un invito valido." + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "L'utente non esiste." + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Questo utente fa già parte del progetto." + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "Indirizzo email malformato." + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Progetto senza proprietario" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "Sei arrivato al tuo limite attuale di iscrizioni per progetti privati" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "Sei arrivato al tuo limite attuale di iscrizioni per progetti pubblici" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "Sei arrivato al tuo limite attuale di membri in attesa di approvazione" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Sprint futuri" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Termine di progetto" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Token non valido" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Il token e' scaduto" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" +"Tag '{value}' non corretto. Il colore non e' un codice HEX corretto o vuoto." + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"Tag '{value}' non corretto. Deve essere il nome o una coppia '[\"name\", " +"\"hex color/\" | null]'." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "Etichetta '{value}' non valida. Deve essere il nome dell'etichetta." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "tags" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "colori dei tag" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Questo tag esiste gia'." + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "Il colore non e' un codice HEX valido." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "Il tag non esiste." + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "Non hai i permessi per aggiungere questo sprint a questo compito." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" +"Non hai i permessi per aggiungere questa storia utente a questo compito." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "Non hai i permessi per aggiungere questo stato a questo compito." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "ordine della storia utente" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "ordine del pannello dei compiti" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "è sotto aspirina" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "milestone id non valido." + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "id stato attivita' non valido." + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "id storia utente non valido." + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" +"id stato attivita' non valido. Lo stato deve appartenere allo stesso " +"progetto." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" +"id storia utente non valido. La storia utente deve appartenere allo stesso " +"progetto." + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" +"id milestone non valido. La milestone deve appartenere allo stesso progetto." + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" +"id stati attivita' non validi. Tutte le attivita' devono appartenere allo " +"stesso progetto e, se esiste, allo stesso stato, storia utente e/o milestone." + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "qualcuno" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

E adesso qualche parola dal collega
che è stato così " +"gentile da invitarti

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Accetta l'invito in Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Accetta il tuo invito" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"E adesso qualche parola dal collega che è stato così gentile da :\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Accetta l'invito in Taiga seguendo il seguente link:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Invito a partecipare al progetto '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Aggiunto al progetto '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s dice:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

Da adesso in avanti, il tuo nuovo status per questo progetto sarà " +"\"admin\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s dice:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"Da ora in poi, il tuo nuovo stato per questo progetto sara' \"admin\".\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] Offerta di trasferimento della proprieta' del progetto " +"accettata!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s dice:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

Se vuoi, puoi ancora provare a trasferire la proprietà del progetto " +"ad un'altra persona.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "Richiedi trasferimento ad un'altra persona" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s dice:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"Se vuoi, puoi ancora provare a trasferire la proprietà del progetto ad " +"un'altra persona.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "Richiedi il trasferimento ad un'altra persona:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] Trasferimento della proprieta' del progetto rifiutato\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

Clicca \"Continua\" se vuoi cominciare il trasferimento di " +"progetto dal pannello di amministrazione.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Continua" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"Per favore, vai alle impostazioni del tuo progetto se vuoi cominciare il " +"trasferimento di progetto dal pannello di amministrazione.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Vai alle tue impostazioni di progetto:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s] Richiesta di trasferimento della proprieta' del progetto\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

Ciao %(receiver_name)s,

\n" +"

%(owner_name)s, il proprietario attuale del progetto \"%(project_name)s\" " +"vorrebbe che tu ne diventassi il nuovo proprietario.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s dice:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

Clicca \"Continua\" per accettare o rifiutare questa proposta.\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"Ciao %(receiver_name)s,\n" +"%(owner_name)s, il proprietario corrente del progetto \"%(project_name)s\" " +"vorrebbe diventare il nuovo proprietario di progetto.\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s dice:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"Per favore, vai al seguente link per accettare o rifiutare questa proposta.\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" +"Accetta o rifiuta la richiesta di trasferimento della proprieta' del " +"progetto:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s] Offerta di trasferimento della proprieta' del progetto\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Il prodotto agile \"backlog\" su Scrum è una lista contenente brevi " +"descrizioni di tutte le funzionalità desiderate nel prodotto. Quando " +"applichi Scrum non è necessario iniziare un progetto elencando " +"dettagliatamente tutta la documentazione necessaria. Il backlog Scrum, in " +"questo modo, può crescere e cambiare man mano che si apprendono le " +"caratteristiche del prodotto e dei suoi clienti" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban è un metodo per gestire il lavoro sulla conoscenza con un enfasi " +"sulle consegne da fare in tempo, mentre consente di non sovraccaricare i " +"membri del team. Con questo approccio il processo, dalla definizione di un " +"compito alla sua consegna ai clienti, viene mostrato ai partecipanti e ai " +"membri del team, in modo che possano organizzare il lavoro." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Nuovo" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Pronto" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "In via di sviluppo" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Pronto per il test" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Fatto" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Archiviato" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Concluso" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Necessita di informazioni" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Posposto" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Rifiutato" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Errore" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Domanda" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Miglioramento" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Basso" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normale" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Alto" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Lista dei desideri" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Minore" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Importante" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Critico" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Design" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Front" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Back" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Proprietario prodotto" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Soggetto interessato" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"Non hai i permessi per aggiungere questo sprint a questa storia utente." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "Non hai i permessi per aggiungere questo stato a questa storia utente." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "id ruolo '{role_id}' non valido" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "id punti '{points_id}' non valido" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Stiamo generando la storia utente #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "ruolo" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "ordine del backlog" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "ordine dello sprint" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "ordine kanban" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "data di termine" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "generato da un problema" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Non c'è nessuna storia utente con questo ID" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"id stato storia utente non valido. Lo stato deve appartenere allo stesso " +"progetto." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"id storia utente non valido. La storia utente deve appartenere allo stesso " +"progetto." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"id storia utente non valido. La storia utente deve appartenere allo stesso " +"progetto." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Non c'è nessuno progetto con questo ID" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "L'utente esiste ancora nel progetto" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Ruolo di progetto non valido" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "L'utente deve essere un contatto valido" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "Il responsabile del progetto deve essere un amministratore." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "Almeno un utente deve essere un amministratore di questo progetto." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" +"id ruolo non valido. Tutti i ruoli devono appartenere allo stesso progetto." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Opzioni predefinite" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Stati della storia utente" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Punti" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Stati del compito" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Stati del problema" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Tipologie del problema" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Priorità" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Criticità" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Ruoli" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Voti" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Voto" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "il parametro 'contenuto' è obbligatorio" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "Il parametro 'ID progetto' è obbligatorio" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "ultima modificatore" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Controlla le API della storie per la differenza esatta" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Membro del Progettto" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Membri del Progetto" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Detentore del progetto" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Detentori del progetto" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "membri" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Termine di progetto" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Termine di progetto" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "E-mail duplicata" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Username o e-mail non validi" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "E' necessario il parametro della password corrente" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "E' necessario il parametro della nuovo password" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Password corrente non valida" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Non valido. Sei sicuro che il token sia corretto e che tu non l'abbia già " +"usato in precedenza?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Non valido. Sicuro che il token sia corretto?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "Mail inviata con successo!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "Stato del super-utente" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Definisce che questo utente ha tutti i permessi senza assegnarglieli " +"esplicitamente." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "nome utente" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" +"Richiede 30 caratteri o meno. Deve comprendere: lettere, numeri e caratteri " +"come /./-/_" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Inserisci un nome utente valido." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "attivo" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Definisce se questo utente debba essere trattato come attivo. Deseleziona " +"questo invece di eliminare gli account." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografia" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "fotografia" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "data di inizio partecipazione" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "data di inizio partecipazione" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "lingua predefinita" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "tema predefinito" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "timezone predefinita" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "colora i tag" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "token e-mail" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "nuovo indirizzo e-mail" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "numero massimo di progetti privati di tua proprietà" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "numero massimo di progetti pubblici di tua proprietà" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "numero massimo di membri per ogni progetto privato di tua proprietà" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "numero massimo di membri per ogni progetto pubblico di tua proprietà" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "permessi" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Il nome utente o la password non corrispondono all'utente." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] cambia la mail" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] recupero della password" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" Puoi eliminare il tuo account da questo servizio cliccando qui\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Puoi eliminare il tuo account da questo servizio: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Sei stato Taigazzato!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "non valido" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Nome utente non valido. Provane uno diverso." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"Un valore di chiave duplicato viola il vincolo unico. La chiave '{}' esiste " +"già." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "chiave" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "chiave segreta" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "codice di stato" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "dati della richiesta" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "header della richiesta" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "dati della risposta" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "header della risposta" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "durata" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "Indirizzo IP non consentito" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "" +#~ "id milestone non valido. La milestone deve appartenere allo stesso " +#~ "progetto." + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "" +#~ "id storia utente non validi. Tutte le storie utenti devono appartenere " +#~ "allo stesso progetto e, se esiste, allo stesso stato e milestone." + +#~ msgid "Personal info" +#~ msgstr "Informazioni personali" + +#~ msgid "Permissions" +#~ msgstr "Permessi" + +#~ msgid "Restrictions" +#~ msgstr "Restrizioni" + +#~ msgid "Important dates" +#~ msgstr "Date importanti" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/ja/LC_MESSAGES/django.po b/taiga/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 000000000..ec4e7653e --- /dev/null +++ b/taiga/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,4885 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Akihiro YAGASAKI , 2016 +# Fumisato Shimazaki , 2020 +# KAMIMURA, Masaharu , 2016 +# KAMIMURA, Masaharu , 2016 +# MH35 , 2018 +# Nikita K , 2016 +# Shun Yanaura , 2016 +# Suguru Sato , 2016 +# Tomonori Tanabe , 2015 +# Toshiyuki Kawanishi , 2019 +# MH35 , 2018 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-02-26 11:31+0000\n" +"Last-Translator: Taiga Dev Team \n" +"Language-Team: Japanese (http://www.transifex.com/taiga-agile-llc/taiga-back/" +"language/ja/)\n" +"Language: ja\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "このログインタイプは無効です。" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "利用規約とプライバシーポリシーに同意する必要があります" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "見つかりませんでした。" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "username が間違っています" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "必須です. 255文字以下. 半角英数字,記号 /./-/_ が使用できます." + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Username は既に使用されています." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Email はすでに使用されています." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "User は既に登録されています." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "ユーザー" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "このフィールドは必須です." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "値が正しくありません." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' は True または False である必要があります." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"英数文字、アンダースコア、ハイフンを含む正しいスラグを入力してください。" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"有効な選択肢を選択してください。%(value)sは選択できる選択肢ではありません。" + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "そのメールドメインは許可されていません。" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "正しいメールアドレスを入力してください." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" +"日付のフォーマットが間違っています。このフォーマットを利用してください。%s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" +"日時のフォーマットが間違っています。このフォーマットを利用してください。%s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" +"時刻のフォーマットが間違っています。このフォーマットを利用してください。%s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "整数を入力してください。" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "値がこの値以下であることを確認してください。%(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "値がこの値以上であることを確認してください。%(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\"は小数である必要があります。" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "数字を入力してください。" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "桁数が%s以下であることを確認してください。" + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "小数点以下の桁数は%sより少ないことを確認してください。" + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "整数部分の桁数は%s以下であることを確認してください。" + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "ファイルが送信されませんでした. フォームのエンコードを確認して下さい." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "ファイルが送信されませんでした." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "送信されたファイルは空です." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"ファイル名の文字数は%(max)d以下であることを確認してください。現在は%(length)d" +"字です。" + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" +"ファイルをサブミットするか、クリアチェックボックスを入れるか、どちらかだけし" +"てください。" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"正しい画像をアップロードしてください。アップロードしたファイルは画像じゃない" +"か破損しています。" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "ブロックされた要素" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "ページは「最後」じゃないか、整数に変換することができません。" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "このページは無効です。(%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "許可定義は無効です。" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "pk'%s'は無効です。オブジェクトが存在しません。" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" +"タイプが正しくありません。予想したタイプはpk、受け取ったタイプは%sです。" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "%s=%sであるオブジェクトは存在しません。" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "URLがマッチしないためハイパーリンクは無効です。" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "URLのマッチが正しくないためハイパーリンクは無効です。" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "設定エラーのためハイパーリンクは無効です。" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "オブジェクトが存在しないためハイパーリンクは無効です。" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" +"タイプが正しくありません。予想したタイプはURLの配列、受け取ったタイプは%sで" +"す。" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "無効なデータ" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "未入力" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "新しいアイテムは作成できません。既存アイテムを更新してください。" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "アイテムの配列を予想していました。" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "見つかりませんでした。" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "権限がありません。" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "サーバーアプリケーションのエラー" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "接続エラー" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "不正なリクエスト" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "認証情報が正しくありません。" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "認証情報が未入力です。" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "この動作を行う権限がありません。" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "'%s'メソッドが許可されていません。" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "リクエストのAcceptヘッダーを満たすことができませんでした。" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "リクエストのメディアタイプ'%s'に対応していません。" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "リクエストはスロットルされました。" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "%d %s秒後に利用可能になる見込みです。" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "予期せぬエラー" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "見つかりませんでした。" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "このエンドポイントはこのメソッドに対応していません。" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "引数の数が正しくありません。" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "データのバリデーションエラー" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "引数が不正か無効による整合性エラー" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "前提条件エラー" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "新しいプロジェクトを作成するスペースがありません。" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "パラメータタイプのフィルターエラー" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project'は整数である必要があります。" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "あなたはTaiga化されました" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Twitterをフォローする" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "GitHubからコードを入手する" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Updates" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Updates" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

コメント:

\n" +"

%(comment)s

" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +"コメント: %(comment)s" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "少なくとも1つの役割が必要です." + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "ダンプファイルが必要です." + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "ダンプフォーマットが正しくありません" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "プロジェクトデータのインポートエラー" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "役割のインポートエラー" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "メンバーシップのインポートエラー" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "プロジェクトアトリビュートリストのインポートエラー" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "カスタムアトリビュートのインポートエラー" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "スプリントのインポートエラー" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "チケットのインポートエラー" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "ユーザストーリのインポートエラー" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "エピックのインポートエラー" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "タスクのインポートエラー" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "Wikiページのインポートエラー" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "Wikiリンクのインポートエラー" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "タグのインポートエラー" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "タイムラインのインポートエラー" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "プロジェクトをインポート中に予期せぬエラー" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "プロジェクトダンプの生成エラー" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"ユーザー{user_full_name} <{user_email}>のダンプのロードエラー\"\n" +"\n" +"\n" +"原因\n" +"-------\n" +"{reason}\n" +"\n" +"詳細:\n" +"--------\n" +"{details}\n" +"\n" +"エラートレース\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "プロジェクトダンプのロードエラー" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "プロジェクトダンプのロードエラー" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "詳細な情報がありません" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s]プロジェクトのダンプが生成されました。" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] プロジェクトダンプがインポートされました。" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\"がプロジェクトに見つかりませんでした。" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "コンテントが無効です。正しい形式は {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "認証が必要です。" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "名前" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "アイコンのURL" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "ウェブ" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "説明" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "次のURL" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "アプリケーション" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "トークンが間違っています" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "フルネーム" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "メールアドレス" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "コメント" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "作成日時" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

フィードバック

\n" +"

Taigaが%(full_name)s <%(email)s>からフィードバックを受け付けました。\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

コメント

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "追加情報" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- 差出人 %(full_name)s <%(email)s>\n" +"---------\n" +"- コメント\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- 追加情報" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] %(full_name)sさんからのフィードバック <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "プロジェクトは存在していません。" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "無効なシグネチャー" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"{platform}からのコメント\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "イッシューコメントの情報は無効です。" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "{platform}からイッシューが作成されました。" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "イッシュー情報は無効です。" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "無効なユーザー" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"コミット{platform}更新しました。\n" +"\n" +" - ステータス: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" +"このイッシューは{platform}コミット内でメンションされまし" +"た。\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "参照された要素は存在しません。" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "ステータスは存在しません。" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "プロジェクトを見る" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "マイルストーンを見る" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "エピックを見る" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "ユーザストーリを見る" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "タスクを見る" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "課題 を表示" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "ウィキページを見る" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "ウィキリンクを見る" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "マイルストーンを追加する" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "マイルストーンを変更する" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "マイルストーンを削除する" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "エピックを追加する" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "エピックを変更する" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "エピックをコメントする" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "エピックを削除する" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "ユーザストーリを見る" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "ユーザストーリを追加する" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "ユーザストーリを変更する" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "ユーザストーリをコメントする" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "ユーザストーリを削除する" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "タスクを追加する" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "タスクを変更する" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "タスクをコメントする" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "タスクを削除する" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "イッシューを追加する" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "イッシューを変更する" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "イッシューをコメントする" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "イッシューを削除する" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "ウィキページを追加する" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "ウィキページを変更する" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "ウィキページをコメントする" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "ウィキページを削除する" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "ウィキリンクを追加する" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "ウィキリンクを変更する" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "ウィキリンクを削除する" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "プロジェクトを変更する" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "プロジェクトを削除する" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "メンバーを追加する" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "メンバーを削除する" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "管理者プロジェクトの値" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "管理者のロール" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "モジュール" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "初期値" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "アクティビティ" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "ファン" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "オーナー" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "公開にする" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} 公開に成功しました。" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "プライベートにする" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} プライベートにするのに成功しました。" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "引数は不足している" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "画像形式が正しくありません" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "無効なユーザーID" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "このユーザーは存在しません。" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "comment is required" +msgid "Email is required" +msgstr "コメントが必須" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"プロジェクトにオーナーと、1人以上のアクティブな管理者であるユーザーが必要で" +"す。" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "部分的な更新に対応していません。" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "プロジェクト" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "コンテントタイプ" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "オブジェクトID" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "更新日時" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "添付ファイル" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "は廃止予定である" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "並べ替え" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "カスタム" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "このプロジェクトは支払い不能によりブロックされています。" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "このプロジェクトは管理スタッフによりブロックされました。" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "このプロジェクトはオーナーの辞退によりブロックされています。" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "このプロジェクトは削除中のためブロックされています。" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "テキスト" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "マルチラインテキスト" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "日時" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "URL" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "ドロップダウン" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "チェックボックス" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "タイプ" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "値" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "エピック" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "ユーザストーリ" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "タスク" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "イッシュー" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "同じ名前のものがすでに存在します。" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "このエピックにこのステータスを付与する権限がありません。" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "ステータス" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "件名" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "色" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "担当者" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "クライアントの要件" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "チームの要件" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "ユーザストーリ" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "外部参照" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "このIDのエピックは存在しません。" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "コメントが必須" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "削除されたコメントを編集できません。" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "コメントがすでに削除済みです。" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "コメントが削除されていません。" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "変更する" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "作成する" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "削除する" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "from" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "to" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "非推奨" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "非推奨ではない" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "to:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "from:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "差出人" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "宛先" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "コンテント" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "ブロックされたノート" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "スプリント" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "このイッシューにこのスプリントを付与する権限がありません。" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "このイッシューにこのステータスを付与する権限がありません。" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "このイッシューにこの深刻度を付与する権限がありません。" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "このイッシューにこの優先度を付与する権限がありません。" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "このイッシューのこのタイプを付与する権限がありません。" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "深刻度" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "優先度" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "マイルストーン" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "完了日時" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "いいね" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "いいねの数" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "スラグ" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "開始予定日時" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "完了予定日時" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "このIDのマイルストーンは存在しません。" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "はブロックされています。" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}'パラメータは必須です。" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project'パラメータは必須です。" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "メール" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "トークン" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "招待状の追加テキスト" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "このユーザーはすでにプロジェクトのメンバーです。" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "ロゴ" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "メンバー" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The user doesn't exist" +msgid "User does not exist." +msgstr "このユーザーは存在しません。" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "このユーザーは既にプロジェクトに追加されています." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "新しいEメールアドレス" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "プロジェクト完了" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "カンバン" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s がコメント:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "続ける" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "プロジェクト設定へ移動する:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s がコメント:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "スクラム" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "新規" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "準備中" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "処理中" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "テスト待ち" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "完了" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "アーカイブ" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "終了" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "情報が必要" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "延期" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "拒否" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "バグ" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "問い合わせ" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "拡張" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "低い" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "通常" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "高い" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "やりたい事" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "マイナー" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "重要" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "危険" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "デザイン" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "フロントエンド" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "バックエンド" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "プロダクトオーナー" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "ステークホルダー" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "役割" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "バックログ順" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "スプリント順" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "完了日" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "ユーザーストーリーステータス" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "点数" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "タスクステータス" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "課題ステータス" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "課題タイプ" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "優先度" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "重要度" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "権限" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "投票" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "投票" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "最終更新者" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "プロジェクトメンバー" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "プロジェクトメンバー" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "ID" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "プロジェクト管理者" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "プロジェクト管理者" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "メンバー" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "プロジェクト完了" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "プロジェクト完了" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "重複メール" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "現在のパスワードが無効です" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "管理者の状態" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "ユーザー名" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "有効なユーザー名を入力してください。" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "有効" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "経歴" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "写真" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "参加日" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "参加日" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "既定の言語" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "既定のテーマ" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "既定のタイムゾーン" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "タグを色付け" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "Eメールトークン" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "新しいEメールアドレス" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "非公開プロジェクトの最大数" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "公開プロジェクトの最大数" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "非公開プロジェクトごとのメンバーの最大数" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "公開プロジェクトごとのメンバーの最大数" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "アクセス権" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "無効" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"重複したキーの値が一意性制約に違反しています。キー '{}' は既に存在していま" +"す。" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "秘密鍵" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "ステータスコード" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "リクエストデータ" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "リクエストヘッダー" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "レスポンスデータ" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "レスポンスヘッダー" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "期限" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "許可されていないIPアドレス" + +#~ msgid "Personal info" +#~ msgstr "個人情報" + +#~ msgid "Permissions" +#~ msgstr "アクセス権" + +#~ msgid "Restrictions" +#~ msgstr "制限事項" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/ko/LC_MESSAGES/django.po b/taiga/locale/ko/LC_MESSAGES/django.po new file mode 100644 index 000000000..fbdeabc5b --- /dev/null +++ b/taiga/locale/ko/LC_MESSAGES/django.po @@ -0,0 +1,5207 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# namjae , 2021 +# jae kwon park , 2017 +# Jonghyuk Baik , 2017 +# 안민규 , 2017 +# 안민규 , 2017 +# mcsong , 2015 +# mcsong , 2015 +# Sori Kang , 2018 +# Theodore Leesuk Kim , 2017 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-03-12 02:35+0000\n" +"Last-Translator: namjae \n" +"Language-Team: Korean (http://www.transifex.com/taiga-agile-llc/taiga-back/" +"language/ko/)\n" +"Language: ko\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "유효하지 않은 로그인 형태" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "공개 사용자 가입이 비활성화되었습니다." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "서비스 약관 및 개인 정보 보호 정책에 동의해야합니다" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "유효하지 않은 회원가입 형태" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "찾지 못함" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "프로젝트에 유효하지 않은 역할입니다." + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "토큰이 유효하지 않습니다." + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "유효하지 않은 아이디" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "필수. 255자 이하. 문자, 숫자 그리고 /./-/_ 만 사용 가능합니다'" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "유효하지 않은 성명" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "이미 사용중인 아이디입니다." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "이미 사용중인 이메일입니다. " + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "이미 등록된 사용자입니다. " + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "새 사용자 생성시 오류." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "유저" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "토큰이 유효하지 않습니다." + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no id" +msgstr "토큰이 만료되었습니다." + +#: taiga/auth/tokens.py:138 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no type" +msgstr "토큰이 만료되었습니다." + +#: taiga/auth/tokens.py:141 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has wrong type" +msgstr "토큰이 만료되었습니다." + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +#, fuzzy +#| msgid "Token has expired" +msgid "Token '{}' claim has expired" +msgstr "토큰이 만료되었습니다." + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "토큰이 유효하지 않습니다." + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "필수 입력필드입니다." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "유효하지 않은 값." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' 값은 반드시 True 또는 False이어야 합니다. " + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"문자, 숫자, 언더 스코어(_)또는 하이픈(-)으로 이루어진 올바른 '슬러그'를 입력" +"하세요." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"%(value)s 는 사용 가능한 선택 중 하나가 아닙니다. 올바른 것을 선택하세요." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "당신의 이메일 도메인은 허용되지 않았습니다" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "이메일 주소를 입력하세요." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" +"날짜 형식이 잘못되었습니다. %s 대신에 아래의 포맷중에 하나를 사용해 주세요." + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" +"날짜와 시간의 형식이 잘못되었습니다. %s 대신에 아래의 포맷중에 하나를 사용해 " +"주세요." + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" +"시간 형식이 잘못되었습니다. %s 대신에 아래의 포맷중에 하나를 사용해 주세요." + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "번호 전체를 입력하세요." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "이 값은 %(limit_value)s 보다 작거나 같아야 합니다." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "이 값은 %(limit_value)s 보다 크거나 같아야 합니다." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" 값은 부동 소수점이어야합니다." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "숫자를 입력하세요." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "총계는 %s 자릿수가 넘지 않아야 합니다." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "%s 자리 이하 여야합니다." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "소수점 앞이 %s 자리 이하인지 확인하십시오." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "제출된 파일이 없습니다. 폼에서 인코딩 형식을 확인하세요." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "제출된 파일이 없습니다." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "제출된 파일이 비어있습니다." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "이 파일 이름의 %(max)d 자 (%(length)d)를 초과하지 않도록하십시오." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "파일을 제출하거나 모두 지우기 체크박스를 선택하십시오." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"유효한 이미지를 업로드하십시오. 업로드 한 파일이 이미지가 아니거나 손상된 이" +"미지입니다." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "차단된 엘리먼트" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "페이지가 '마지막'이 아니며 int로 변환 될 수도 없습니다." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "잘못된 페이지 (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "권한 정의가 잘못되었습니다." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "잘못된 pk '%s' - 객체가 존재하지 않습니다." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "pk값을 받을거라 예상했지만 %s 를 받음. 정확하지 않은 형식" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "%s = %s의 객체가 존재하지 않습니다." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "잘못된 하이퍼 링크 - 일치하는 URL 없음" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "잘못된 하이퍼 링크 - 잘못된 URL 일치" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "구성 오류로 인한 잘못된 하이퍼 링크" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "유효하지 않은 하이퍼링크 - 객체가 존재하지 않음." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "url 문자를 받을거라 예상했지만 %s 를 받음. 정확하지 않은 형식" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "데이터가 유효하지 않습니다." + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "제공된 입력 없음" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "새 항목을 만들 수 없으며 기존 항목 만 수정 할 수 있습니다." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "예상되는 항목 목록." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "찾지 못함" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "권한이 없습니다" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "서버 애플리케이션 에러" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "연결 에러" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "형식에 어긋난 요청." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "정확하지 않은 인증 자격 증명." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "인증 자격 증명이 제공되지 않았습니다." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "이 작업을 할 권한이 없습니다." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "메서드 '%s' 가 허용되지 않았습니다." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "요청의 Accept 헤더를 만족시킬 수 없습니다." + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "요청시 지원되지 않는 미디어 유형 '%s'" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "요청이 제한되었습니다." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "%d 초%s에서 사용할 수 있습니다." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "알 수 없는 에러" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "찾지 못함." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "이 끝점에서 지원되지 않는 메서드입니다." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "잘못된 인수" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "데이터 검증 에러" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "잘못되었거나 유효하지 않은 인수에 대한 무결성 오류" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "필수조건 오류" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "더 많은 프로젝트를 위한 공간이 남지 않았습니다." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s 리스트가 아님" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "필터 매개 변수 유형에 오류가 있습니다." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project'는 반드시 정수이어야 합니다." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "타이가" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "당신은 타이가화 되었습니다." + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

오픈 소스 애자일 프로젝트 " +"관리 도구 %(product_name)s에 오신 것을 환영합니다.

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" 이메일 알람 설정 또는 구독 취" +"소\n" +"  • \n" +" 타이가 지원\n" +"  • \n" +" 문의하기\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "트위터 팔로우" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "GitHub에서 코드 가져오기" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[타이가] 업데이트" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "업데이트" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

댓글:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" 댓글: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "호스트 액세스 오류" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "IP 액세스 오류" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "유저 스토리가 생성되었습니다" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "유저 스토리가 변경되었습니다" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "유저 스토리가 삭제되었습니다" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "유저스토리 #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "태스크가 생성되었습니다" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "태스크가 변경되었습니다" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "태스크가 삭제되었습니다" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "태스크 #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "이슈가 생성되었습니다" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "이슈가 변경되었습니다" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "이슈가 삭제되었습니다" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "이슈: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "위키 페이지가 생성되었습니다" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "위키 페이지가 변경되었습니다" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "위키 페이지가 삭제되었습니다" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "위키 페이지: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "스프린트가 생성되었습니다" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "스프린트가 변경되었습니다" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "스프린트가 삭제되었습니다" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "스프린트: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "최소 한 개의 역할이 필요합니다." + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "덤프 파일이 필요함" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "잘못된 덤프 포맷" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "프로젝트 데이터를 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "역할을 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "회원을 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "프로젝트 속성 목록을 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "기본 프로젝트 속성값들 가져오기 오류" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "사용자 정의 속성을 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "스프린트를 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "이슈를 가져오는 중 오류가 발생하였습니다" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "유저 스토리를 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "에픽을 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "태스크를 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "위키 페이지를 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "위키 링크를 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "태그를 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "타임라인을 가져오는 중 오류가 발생하였습니다." + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "예상치 못한 프로젝트 가져 오기 오류가 발생하였습니다." + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "더 이상의 비공개 프로젝트를 소유하실 수 없습니다." + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" +"이 프로젝트는 당신의 비공개 프로젝트 최대 수용 인원수에 도달하였습니다." + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "더 이상의 공개 프로젝트를 소유하실 수 없습니다." + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "이 프로젝트는 당신의 공개 프로젝트 최대 수용 인원수에 도달하였습니다." + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "프로젝트 덤프를 생성하는 중 오류가 발생하였습니다." + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"{user_full_name} <{user_email}> 덤프를 로딩하던중 오류가 발생하였습니다.:\"\n" +"\n" +"\n" +"이유:\n" +"-------\n" +"{reason}\n" +"\n" +"상세정보:\n" +"--------\n" +"{details}\n" +"\n" +"에러 추적:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "프로젝트 덤프를 로드하는 중 오류가 발생하였습니다." + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "프로젝트 덤프 파일을 로드하는 중 오류가 발생하였습니다." + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "-- 상세 정보가 없습니다. --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

프로젝트 덤프가 생성되었습니다.

\n" +"

안녕하세요 %(user)s 님,

\n" +"

%(project)s 프로젝트의 덤프가 생성되었습니다.

\n" +"

여기서 다운로드 하실 수 있습니다.:

\n" +" 덤" +"프 파일 다운로드\n" +"

이 파일은 %(deletion_date)s 에 삭제됩니다.

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"안녕하세요 %(user)s 님,\n" +"\n" +"%(project)s 프로젝트의 덤프가 생성되었습니다. 여기서 다운로드 하실 수 있습니" +"다.:\n" +"\n" +"%(url)s\n" +"\n" +"이 파일은 %(deletion_date)s 에 삭제됩니다.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] 프로젝트 덤프가 생성되었습니다." + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

안녕하세요 %(user)s 님,

\n" +"

%(project)s 프로젝트를 내보낼 수 없었습니다.

\n" +"

타이가 시스템 관리자에게 알렸습니다.
다시 시도하거나 지원팀에게 " +"문의해주세요.\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"안녕하세요 %(user)s 님,\n" +"\n" +"%(error_message)s\n" +"%(project)s 프로젝트를 내보낼 수 없었습니다.\n" +"\n" +"타이가 시스템 관리자에게 알렸습니다.\n" +"\n" +"다시 시도하거나 지원팀에게 문의해주세요. %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

안녕하세요 %(user)s 님,

\n" +"

프로젝트를 가져올 수 없었습니다.

\n" +"

%(product_name)s 시스템 관리자에게 알렸습니다.
다시 시도하거나 지" +"원팀에 문의바랍니다.\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

에러 상세

\n" +"
%(details)s
\n" +" " + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"안녕하세요 %(user)s님,\n" +"\n" +"%(error_message)s\n" +"\n" +"프로젝트를 가져올 수 없었습니다.\n" +"\n" +"%(product_name)s 시스템 관리자에게 통보되었습니다.\n" +"\n" +"다시 시도하거나 지원팀에게 문의해주세요. %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[타이가] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

프로젝트 덤프 가져오기 완료

\n" +"

안녕하세요 %(user)s 님,

\n" +"

프로젝트 덤프를 가져오기 완료하였습니다.

\n" +" %(project)s 프로젝트로 가기\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"안녕하세요 %(user)s 님,\n" +"\n" +"프로젝트 덤프를 가져오기 완료하였습니다.\n" +"\n" +"여기에서 %(project)s 프로젝트를 확인할 수 있습니다:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] 프로젝트 덤프를 가져왔습니다" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" 이 프로젝트에서 찾을 수 없습니다." + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "잘못된 컨텐츠. 반드시 {\"key\": \"value\",...} 형태이어야 합니다." + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "유효하지 않은 사용자 지정 필드를 가지고 있습니다." + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "중복된 이름" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" +"어떤 에픽이 외부 프로젝트(%(project)s)에 관련된 스토리를 가지고 있고 가져오기" +"를 수행할 수 없습니다" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "인증 필요" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "이름" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "아이콘 url" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "웹" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "설명" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "다음 url" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "어플리케이션" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "유효하지 않은 토큰" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "성명" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "이메일 주소" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "댓글" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "생성된 날짜" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

피드백

\n" +"

타이가가 %(full_name)s <%(email)s>의 피드백을 받았습니다.

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

댓글

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "추가 정보" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- 보낸이: %(full_name)s <%(email)s>\n" +"---------\n" +"- 댓글:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- 추가 정보:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[타이가] %(full_name)s <%(email)s>의 피드백\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "페이로드가 유효한 json이 아닙니다." + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "프로젝트가 존재하지 않습니다." + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "좋지 않은 기호" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" +"[{user_name}]({user_url} \"{user_name}의 {platform} 프로필 보기\")님의 " +"[{platform}#{number}]({comment_url} \"댓글로 가기\")에 남긴 말:\n" +"\n" +"\"{comment_message}\"" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"{platform} 플랫폼으로부터의 댓글:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "이슈 댓글 정보가 유효하지 않습니다" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"[{user_name}]({user_url} \"{user_name}의 {platform} 프로필 보기\")님에 의해 " +"[{platform}#{number}]({url} \"이슈로 가기\")에서 생성된 이슈." + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "{platform}에서 이슈가 생성되었습니다." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"[{user_name}]({user_url} \"{user_name}의 {platform} 프로필 보기\")님에 의해 " +"[{platform}#{number}]({url} \"이슈로 가기\")에서 변경된 이슈." + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "{platform}에서 변경된 이슈." + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"[{user_name}]({user_url} \"{user_name}의 {platform} 프로필 보기\")님에 의해 " +"[{platform}#{number}]({url} \"이슈로 가기\")에서 완료된 이슈." + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "{platform}에서 이슈 완료됨." + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"[{user_name}]({user_url} \"{user_name}의 {platform} 프로필 보기\")님에 의해 " +"[{platform}#{number}]({url} \"이슈로 가기\")에서 재개된 이슈." + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "{platform}에서 재개됨." + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "유효하지 않은 이슈 정보" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "알수 없는 유저" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{platform} 커밋으로부터 상태가 수정되었습니다..\n" +"\n" +" - 상태: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "{platform} 커밋에서 이 이슈가 언급되었습니다. \"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "참조된 엘리먼트가 존재하지 않습니다." + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "상태가 존재하지 않습니다." + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "프로젝트 값이 필요합니다." + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Asana API 요청이 올바르지 않습니다." + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "Asana API에 요청하는 중 실패하였습니다." + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Code 값이 필요합니다." + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Asana 프로젝트를 가져오는 중 에러가 발생하였습니다." + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "인증 정보가 유효하지 않습니다." + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "서드 파티 서비스에 실패하였습니다." + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "GitHub프로젝트를 가져오는 중 에러가 발생하였습니다." + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "url값이 필요합니다." + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" +"\n" +" 에러가 발생했습니다; 아마도 지원되지 않는 Jira 버전 때문인 " +"것 같습니다.\n" +" 타이가는 Jira 8.6 릴리즈부터 지원하지 않습니다." + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "유효하지 않은 프로젝트_유형 {}" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "잘못된 지라 서버 설정입니다." + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "인증 토큰이 유효하지 않거나 만료되었습니다." + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Jira 프로젝트를 가져오는 중 에러가 발생하였습니다." + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "PivotalTracker 프로젝트를 가져오는 중 에러가 발생하였습니다." + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Asana 프로젝트를 가져왔습니다" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] GitHub 프로젝트를 가져왔습니다" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Jira 프로젝트를 가져왔습니다" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Trello 프로젝트를 가져왔습니다" + +#: taiga/importers/trello/importer.py:57 +#, fuzzy, python-format +#| msgid "Invalid Request: %s at %s" +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "유효하지 않은 요청: %s 의 %s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, fuzzy, python-format +#| msgid "Unauthorized: %s at %s" +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "인증되지 않음: %s 의 %s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, fuzzy, python-format +#| msgid "Resource Unavailable: %s at %s" +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "자원 사용 불가: %s 의 %s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "Trello 프로젝트를 가져오는 중 에러가 발생하였습니다." + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "프로젝트 보기" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "마일스톤 보기" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "에픽 보기" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "유저 스토리 보기" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "태스크 보기" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "이슈 보기" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "위키 페이지 보기" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "위키 링크 보기" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "마일스톤 추가" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "마일스톤 수정" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "마일스톤 삭제" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "에픽 추가" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "에픽 수정" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "에픽 댓글" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "에픽 삭제" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "유저 스토리 보기" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "유저 스토리 추가" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "유저 스토리 수정" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "유저 스토리 댓글" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "유저 스토리 삭제" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "태스크 추가" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "태스크 수정" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "태스크 댓글" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "태스크 삭제" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "이슈 추가" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "이슈 수정" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "이슈 댓글" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "이슈 삭제" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "위키 페이지 추가" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "위키 페이지 수정" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "위키 페이지 댓글" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "위키 페이지 삭제" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "위키 링크 추가" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "위키 링크 수정" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "위키 링크 삭제" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "프로젝트 수정" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "프로젝트 삭제" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "회원 추가" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "회원 삭제" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "운영자 프로젝트 값" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "운영자 역할" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "모듈" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "기본값" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "활동" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "좋아요" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "소유자" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "공개로 만들기" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} 개를 공개로 만드는데 성공하였습니다." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "비공개로 만들기" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} 개를 비공개로 만드는데 성공하였습니다." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "매개변수가 완전하지 않습니다." + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "이미지 형식이 유효하지 않습니다." + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "사용자 아이디가 유효하지 않습니다." + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "사용자가 존재하지 않습니다." + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "프로젝트 회원이어야 합니다." + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "유저 스토리의 기본 완료 기한 상태를 삭제할 수 없습니다" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "프로젝트가 이미 완료 기한을 가지고 있습니다" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "태스크의 기본 완료 기한 상태를 삭제할 수 없습니다" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "프로젝트가 이미 태스크 완료 기한을 가지고 있습니다" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "이슈의 기본 완료 기한 상태를 삭제할 수 없습니다" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "프로젝트가 이미 이슈 완료 기한을 가지고 있습니다" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "comment is required" +msgid "Email is required" +msgstr "comment가 필수입니다." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"프로젝트는 반드시 소유자가 있어야하며 사용자 중 적어도 한 명이 활성 관리자이" +"어야 합니다." + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "부분 업데이트는 지원되지 않습니다." + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "프로젝트" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "컨텐츠 형태" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "객체 아이디" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "날짜가 변경되었습니다." + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "파일이 첨부되었습니다." + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "지원안함" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "댓글로부터" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "순서" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +#, fuzzy +#| msgid "" +#| "Invalid task ids. All tasks must belong to the same project and, if it " +#| "exists, to the same status, user story and/or milestone." +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" +"태스크 아이디가 유효하지 않습니다. 모든 태스크는 반드시 동일한 프로젝트에 속" +"해야하며, 만약 존재한다면, 같은 상태, 마일스톤이거나 상태, 유저 스토리, 마일" +"스톤이어야 합니다." + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "사용자 정의" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "이 프로젝트는 결제 실패로 인해 차단되었습니다." + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "관리자에 의해 프로젝트가 차단되었습니다." + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "소유자가 남아 있어 프로젝트가 차단되었습니다." + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "이 프로젝트는 삭제되는 동안 차단됩니다." + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +" %(full_name)s 님께" +"서 %(project_name)s 프로젝트에 글을 남겼습니다.\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +"이 메시지는 %(project_name)s 이름의 프로젝트 관리자로 되어있어 수신되었습니" +"다. 타이가 커뮤니티의 회원이 귀하의 프로젝트에 연락하기를 원치 않으시면, 프로젝트 설정 변경을 통해 차단하실 수 " +"있습니다. 프로젝트 회원간의 연락은 영향을 받지 않습니다." + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s 님께서 %(project_name)s 프로젝트에 글을 남겼습니다.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +"이 메시지는 당신이 %(project_name)s 이름의 프로젝트의 관리자로 되어기 때문에 " +"수신되었습니다. 타이가 커뮤니티의 회원이 귀하의 프로젝트에 연락하기를 원치 않" +"으시면, %(project_settings_url)s 에서 당신의 프로젝트 설정을 변경하십시오. 프" +"로젝트 회원간의 대화에는 영향을 받지 않습니다.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s 님께서 %(project_name)s 프로젝트에 메시지를 보냈습니" +"다.\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "텍스트" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "여러 줄의 텍스트" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "서식있는 텍스트" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "날짜" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "형태" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "값" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "에픽" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "유저 스토리" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "태스크" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "이슈" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "이미 동일한 이름이 존재합니다." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "완료 기한" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "이 에픽의 상태를 설정할 권한이 없습니다." + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "참조" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "상태" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "에픽 순서" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "제목" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "색" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "할당됨" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "고객 요구 사항" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "팀 요구 사항" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "유저 스토리" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "외부 참조" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "아이디를 가진 에픽이 없습니다." + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "comment가 필수입니다." + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "삭제된 댓글은 수정할 수 없습니다." + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "댓글이 이미 삭제되었습니다." + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "댓글이 삭제되지 않았습니다." + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "수정" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "생성" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "삭제" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s 역할 포인트" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "보낸이" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "받는이" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "새로운 첨부파일이 추가되었습니다." + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "첨부파일이 수정되었습니다." + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "지원하지 않음" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "아직 지원중임" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "첨부파일이 삭제되었습니다." + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "추가되었습니다." + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "삭제되었습니다." + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "할당되지 않았습니다." + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-삭제되었습니다-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "받는이:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "보낸이:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "추가되었습니다." + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "수정되었습니다." + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "삭제되었습니다." + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "추가되었습니다:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "삭제되었습니다:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "보낸이:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "받는이:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "컨텐츠" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "차단된 노트" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "스프린트" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "이 이슈의 스프린트를 설정할 권한이 없습니다." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "이 이슈의 상태를 설정할 권한이 없습니다." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "이 이슈의 심각도를 설정할 권한이 없습니다." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "이 우선 순위의 형태를 설정할 권한이 없습니다." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "이 이슈를 이 유형으로 설정할 권한이 없습니다." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "심각도" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "우선 순위" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "마일스톤" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "종료된 날짜" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "마일스톤은 프로젝트에 유효하지 않습니다." + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "좋아요" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "좋아요" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "슬러그" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "예측 시작일" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "예측 종료일" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "완료됨" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "가용성" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "예측 시작은 반드시 예측 종료보다 이전이어야 합니다." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "아이디를 가진 마일스톤이 없습니다." + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "모든 유저 스토리는 반드시 동일한 프로젝트에 속해야 합니다." + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "차단됨" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "ref 매개변수가 필요합니다." + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "project 또는 project__slug 매개변수가 필요합니다." + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' 매개변수는 필수입니다." + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' 매개변수는 필수입니다." + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "반드시 프로젝트 회원이어야 합니다." + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "이메일" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "생성일" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "토큰" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "초대장 추가 문자" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "유저 순서" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "이미 프로젝트의 일원입니다." + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "기본 에픽 상태" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "기본 유저 스토리 상태" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "기본 포인트" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "기본 태스크 상태" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "기본 우선순위" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "기본 심각도" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "기본 이슈 상태" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "기본 이슈 유형" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "로고" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "회원" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "마일스톤 합계" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "총 스토리 포인트" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "연락처 활성" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "에픽 활성" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "백로그 활성" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "칸반 활성" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "위키 활성" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "활성 이슈 패널" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "화상회의 시스템" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "화상회의 추가 데이터" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "탬플릿 생성" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "비공개" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "익명 사용자 권한" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "사용자 권한" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "추천됨" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "구인중" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "구인 메모" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "프로젝트 이전 토큰" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "코드 차단됨" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "날짜 시간 수정됨" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "개수" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "지난 주의 좋아요" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "지난 달의 좋아요" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "작년의 좋아요" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "지난 주의 활동" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "지난 달의 활동" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "작년의 활동" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "모듈 설정" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "보관됨" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "작업 진행 한계" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "값" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "기한까지 일수" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "소유자의 기본 역할" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "기본 옵션" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "에픽 상태" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "유저 스토리 상태" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "유저스토리 완료기한" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "포인트" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "태스크 상태" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "태스크 완료기한" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "이슈 상태" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "이슈 유형" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "이슈 완료기한" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "우선순위" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "심각도" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "역할" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "에픽 사용자 정의 속성" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "유저 스토리 사용자 정의 속성" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "태스크 사용자 정의 속성" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "이슈 사용자 정의 속성" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "관련됨" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "모두" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "없음" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "날짜 시간 생성됨" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "내역 항목" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "사용자 알림" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "구독됨" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "지정된 사용자 및 프로젝트에 대한 알림이 있습니다." + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "알림 수준의 값이 유효하지 않습니다." + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"에픽이 수정되었습니다.\n" +"안녕하세요 %(user)s 님, %(changer)s 님께서 %(project)s 프로젝트의 에픽을 수정" +"하셨습니다.\n" +"#%(ref)s %(subject)s 에픽 보기 %(url)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s %(subject)s 에픽이 수정되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s %(subject)s 에픽이 생성되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s %(subject)s 에픽이 삭제되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s %(subject)s 이슈가 수정되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s %(subject)s 이슈가 생성되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s %(subject)s 이슈가 삭제되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(milestone)s\" 스프린트가 수정되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(milestone)s\" 스프린트가 생성되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(milestone)s\" 스프린트가 삭제되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" 태스크가 수정되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" 태스크가 생성되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" 태스크가 삭제되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" 유저 스토리가 수정되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" 유저 스토리가 생성되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" 유저 스토리가 삭제되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(page)s\" 위키 페이지가 수정되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(page)s\" 위키 페이지가 생성되었습니다.\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(page)s\" 위키 페이지가 삭제되었습니다.\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "구독중인 사람들중에 유효하지 않은 사용자가 포함되어 있습니다." + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "버전은 반드시 정수이어야 합니다." + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "버전 매개변수가 유효하지 않습니다." + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "현재 버전과 일치하지 않습니다." + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "버전" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "당신이 소유자이거나 다른 관리자가 없다면 프로젝트를 떠날 수 없습니다." + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "토큰이 어떤 유효한 초대와도 일치하지 않습니다." + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The user doesn't exist" +msgid "User does not exist." +msgstr "사용자가 존재하지 않습니다." + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "이 사용자는 이미 프로젝트의 회원입니다." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "새로운 이메일 주소" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "소유자 없는 프로젝트" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "당신의 현재 비공개 프로젝트 최대 수용 인원수에 도달하였습니다." + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "당신의 현재 공개 프로젝트 최대 수용 인원수에 도달하였습니다." + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "당신의 현재 최대 보류중 회원수 제한에 도달하였습니다." + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "태스크 #%(ref)s" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "앞으로의 스프린트" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "프로젝트 종료" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "토큰이 유효하지 않습니다." + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "토큰이 만료되었습니다." + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "타임라인" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "에픽" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "백로그" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "칸반" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "이슈" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "팀위키" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "이 영역에 접근할 수 없습니다" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" +"'{value}' 태그는 유효하지 않습니다. 색이 유효하지 않은 HEX색 이거나 null 입니" +"다." + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"'{value}' 태그는 유효하지 않습니다. 이름이나 쌍을 이루어야 합니다. " +"'[\"name\", \"hex color/\" | null]'." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "'{value}' 태그는 유효하지 않습니다. 태그 이름이어야 합니다." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "태그" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "태그 색" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "이 태그는 이미 존재합니다." + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "색이 유효하지 않은 HEX색 입니다." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "태그가 존재하지 않습니다." + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "이 태스크의 스프린트를 설정할 권한이 없습니다." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "이 태스크의 유저 스토리를 설정할 권한이 없습니다." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "이 태스크의 상태를 설정할 권한이 없습니다." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "유저 스토리 순서" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "태스크보드 순서" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "아이오케인 입니다." + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "마일스톤 아이디가 유효하지 않습니다." + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "태스크 상태 아이디가 유효하지 않습니다." + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "유저 스토리 아이디가 유효하지 않습니다." + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" +"태스크 상태 아이디가 유효하지 않습니다. 상태는 반드시 같은 프로젝트에 속해야 " +"합니다." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" +"유저 스토리 아이디가 유효하지 않습니다. 유저 스토리는 반드시 같은 프로젝트에 " +"속해야 합니다." + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" +"마일스톤 아이디가 유효하지 않습니다. 마일스톤은 반드시 같은 프로젝트에 속해" +"야 합니다." + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" +"태스크 아이디가 유효하지 않습니다. 모든 태스크는 반드시 동일한 프로젝트에 속" +"해야하며, 만약 존재한다면, 같은 상태, 마일스톤이거나 상태, 유저 스토리, 마일" +"스톤이어야 합니다." + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "모든 태스크는 동일 프로젝트에서 와야합니다" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "누군가" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

당신을 친히 초대할만큼
유쾌하고 좋은 동료들이나 여자사람친" +"구들의 몇 마디

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "타이가로의 초대를 수락합니다." + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "초대를 수락합니다." + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"당신을 친히 초대할만큼 유쾌하고 좋은 동료들이나 여자사람친구들의 몇 마디:\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "타이가로의 초대를 수락하려면 이 링크를 따라가십시오:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[타이가] '%(project)s' 프로젝트로 초대하는 초대장\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[타이가] '%(project)s' 프로젝트가 추가되었습니다.\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s 님의 말:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

이제부터, 이 프로젝트에서 당신의 새로운 지위는 \"관리자\"가 될 것" +"입니다.

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s 님의 말:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"이제부터, 이 프로젝트에서 당신의 새로운 지위는 \"관리자\"가 될 것입니다.\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] 프로젝트 소유권 이전 승인!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s 님의 말:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

원한다면 프로젝트 소유권을 다른 사람에게 이전 할 수 있습니다.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "다른 사람에게 이전 요청" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s 님의 말:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"원한다면 프로젝트 소유권을 다른 사람에게 이전 할 수 있습니다.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "다른 사람에게 이전 요청:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] 프로젝트 소유권 이전 거절\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

관리자 패널에서 프로젝트 이전을 하고 싶으시다면 \"계속\"을 클릭해" +"주세요.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "계속" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"관리자 패널에서 프로젝트 이전을 시작하고 싶으시면 프로젝트 설정으로 이동하세" +"요.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "프로젝트 설정으로 이동:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s] 프로젝트 소유권 이전 요청\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

안녕하세요 %(receiver_name)s 님,

\n" +"

\"%(project_name)s\" 프로젝트의 현재 소유자 %(owner_name)s 입니" +"다. 새로운 프로젝트 소유자가 되고 싶지 않으신가요?

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s 님의 말:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

이 요청을 수락하거나 거절하시려면 \"계속\"를 클릭하세요.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"안녕하세요 %(receiver_name)s 님,\n" +"\"%(project_name)s\" 프로젝트의 현재 소유자 %(owner_name)s 입니다. 새로운 프" +"로젝트 소유자가 되고 싶지 않으신가요?\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s 님의 말:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"이 제안을 수락하거나 거부하려면 다음 링크로 이동하세요.

\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "프로젝트 소유권 이전을 수락하거나 거부하세요.:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s] 프로젝트 소유권 이전 제안\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "스크럼" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"스크럼의 애자일 제품 백 로그는 제품에 필요한 모든 기능의 간략한 설명이 포함" +"된 우선 순위 기능 목록입니다. 스크럼을 적용 할 때 모든 요구 사항을 문서화하" +"기 위해 오랜 시간을 투자하며 프로젝트를 시작할 필요가 없습니다. 스크럼 제품 " +"백 로그는 제품과 고객에 대해 더 많이 알게되면 확대되고 변경 될 수 있습니다." + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"칸반은 팀 구성원에게 과부하가 아닌 정시 전달에 중점을 둔 지식 작업 관리 방식" +"입니다. 이 작업 방식은 태스크 정의로부터 고객에게 전달될 때까지 참여자들이 " +"볼 수 있도록 표시되고 팀 구성원이 대기열로부터 일을 가져올 수 있습니다." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "신규" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "준비" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "작업중" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "테스트 준비 끝" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "완료됨" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "보관됨" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "완료됨" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "정보 필요" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "지연됨" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "거부됨" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "버그" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "질문" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "개선" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "낮음" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "일반" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "높음" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "희망사항" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "부가적" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "중요" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "치명적" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "디자인" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "프론트" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "이전" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "제품 소유자" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "이해관계자" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "이 유저 스토리를 스프린트에 설정할 권한이 없습니다." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "이 유저 스토리의 상태를 설정할 권한이 없습니다." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "'{role_id}' 역할 아이디는 유효하지 않습니다." + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "'{points_id}' 포인트 아이디는 유효하지 않습니다." + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "유저 스토리 생성 #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "역할" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "백로그 순서" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "스프린트 순서" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "칸반 순서" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "종료일" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "이슈로부터 생성됨" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "아이디를 가진 유저 스토리가 없습니다." + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"유저 스토리 상태 아이디가 유효하지 않습니다. 상태는 반드시 동일한 프로젝트에 " +"속해야 합니다." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"유저 스토리 아이디가 유효하지 않습니다. 유저 스토리는 반드시 같은 프로젝트에 " +"속해야 합니다." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"유저 스토리 아이디가 유효하지 않습니다. 유저 스토리는 반드시 같은 프로젝트에 " +"속해야 합니다." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "아이디가 있는 프로젝트가 없습니다." + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "이 프로젝트에는 아직 사용자가 없습니다." + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "프로젝트에 유효하지 않은 역할입니다." + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "사용자는 반드시 유효한 연락처이어야 합니다." + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "프로젝트 소유자는 반드시 관리자이어야 합니다." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "이 프로젝트의 최소 한 명의 사용자는 활성된 관리자이어야 합니다." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" +"유효하지 않은 역할 아이디입니다. 모든 역할은 반드시 동일한 프로젝트에 속해야 " +"합니다." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "기본 옵션" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "유저 스토리 상태" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "포인트" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "태스크 상태" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "이슈 상태" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "이슈 유형" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "우선순위" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "심각도" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "역할" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "투표" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "투표" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content' 매개변수는 필수입니다." + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id' 매개변수는 필수입니다." + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "마지막 수정자" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "확실한 차이점을 보려면 API 내역을 확인하십시오." + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "프로젝트 회원" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "프로젝트 회원" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "아이디" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "프로젝트 소유권" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "프로젝트 소유권" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "회원" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "프로젝트 종료" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "프로젝트 종료" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "중복된 이메일" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "아이디 또는 이메일이 유효하지 않습니다." + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "현재 비밀번호 매개변수가 필요합니다." + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "새로운 비밀번호 매개변수가 필요합니다." + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "현재 비밀번호는 유효하지 않습니다.Invalid current password" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "유효하지 않음, 토큰이 정확하고 이전에 사용하지 않았습니까?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "유효하지 않음, 토큰이 정확합니까?are you sure the token is correct?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "성공적으로 메일을 전송했습니다!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "슈퍼유저 상태" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "이 사용자가 명시적인 할당을 받지 않고 모든 권한을 가지는지 지정합니다." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "아이디" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "필수. 30자 이하. 문자, 숫자 그리고 /./-/_ characters" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "올바른 아이디를 입력해주세요." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "활성" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"이 사용자를 활성 상태로 취급할 것인지 지정합니다. 계정을 삭제하는 대신에 선택" +"을 해제하십시오." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "바이오그래피" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "사진" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "가입한 날짜" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "가입한 날짜" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "기본 언어" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "기본 테마" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "기본 시간대" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "태그 색 칠하기" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "이메일 토큰" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "새로운 이메일 주소" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "소유할 수 있는 비공개 프로젝트의 최대 개수" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "소유할 수 있는 공개 프로젝트의 최대 개수" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "소유한 비공개 프로젝트당 수용할 수 있는 최대 회원수" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "소유한 공개 프로젝트당 수용할 수 있는 최대 회원수" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "권한" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "아이디 또는 비밀번호가 올바르지 않습니다." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[타이가] 이메일 변경" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[타이가] 비밀번호 복구" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" 이 서비스에서 귀하의 계정을 삭제할 수 있습니다. 여기를 클릭하세" +"요.\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"이 서비스에서 귀하의 계정을 삭제할 수 있습니다: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "타이가화 되었습니다!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "유효하지 않음" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "유효하지 않은 아이디입니다. 다른 것으로 다시 시도해주세요." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "키 값이 고유 제한 조건을 위반합니다. '{}'키가 이미 있습니다." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "키" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "비밀 키" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "상태 코드" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "요청 데이터" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "요청 헤더" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "응답 데이터" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "응답 헤더" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "지속시간" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "" +#~ "마일스톤 아이디가 유효하지 않습니다. 마일스톤은 반드시 같은 프로젝트에 속" +#~ "해야 합니다." + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "" +#~ "유저 스토리 아이디가 유효하지 않습니다. 모든 유저 스토리는 반드시 같은 프" +#~ "로젝트에 속해야 하고, 존재한다면, 같은 상태와 마일스톤을 가져야 합니다." + +#~ msgid "Personal info" +#~ msgstr "개인정보" + +#~ msgid "Permissions" +#~ msgstr "권한" + +#~ msgid "Restrictions" +#~ msgstr "제한 사항" + +#~ msgid "Important dates" +#~ msgstr "중요한 날짜" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/lv/LC_MESSAGES/django.po b/taiga/locale/lv/LC_MESSAGES/django.po new file mode 100644 index 000000000..1c293f0a0 --- /dev/null +++ b/taiga/locale/lv/LC_MESSAGES/django.po @@ -0,0 +1,5075 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Oskars G , 2020 +# Oskars G , 2020 +# Sergey Skibust , 2017 +# vsv , 2020 +# vsv , 2020 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-02-26 11:31+0000\n" +"Last-Translator: Taiga Dev Team \n" +"Language-Team: Latvian (http://www.transifex.com/taiga-agile-llc/taiga-back/" +"language/lv/)\n" +"Language: lv\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : " +"2);\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "nederīgs lietotāja vārda tips" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "Publiska reģistrācija nav pieejama" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "Jums jāpiekrīt pakalpojumu noteikumiem un privātuma politikai" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "nederīgs reģistrācijas tips" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "Nav atrasts" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "Projektam šī loma nav derīga" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "Nederīgs tokens" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "Nederīgs lietotājvārds" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Obligāts. 255 rakstzīmes vai mazāk. Burti, cipari un /./-/_ rakstzīmes" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Pilnais vārds nav derīgs" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Lietotājvārds jau tiek izmantots." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "E-pasta adrese jau tiek izmantota." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Lietotājs jau ir reģistrēts." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Kļūda veidojot lietotāju" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "lietotājs" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "Nederīgs tokens" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no id" +msgstr "Novecojis/izlietots tokens" + +#: taiga/auth/tokens.py:138 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no type" +msgstr "Novecojis/izlietots tokens" + +#: taiga/auth/tokens.py:141 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has wrong type" +msgstr "Novecojis/izlietots tokens" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +#, fuzzy +#| msgid "Token has expired" +msgid "Token '{}' claim has expired" +msgstr "Novecojis/izlietots tokens" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Nederīgs tokens" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Šis lauks ir obligāts." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Nederīga vērtība." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s'vērtībai jābūt \"jā/patiess\" vai \"nē/nepatiess\"..." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Ievadiet derīgu “logu”, kas sastāv no burtiem, cipariem, pasvītrojumiem vai " +"defisēm." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "Izdariet derīgu izvēli. %(value)s nav viena no pieejamajām izvēlēm." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Jūsu e-pasta domēns nav atļauts" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Ievadiet derīgu e-pasta adresi." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" +"Datuma formāts nav pareizs. Tā vietā izmantojiet kādu no šiem formātiem: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" +"Datuma:laika formāts nav pareizs. Tā vietā izmantojiet kādu no šiem " +"formātiem: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" +"Laika formāts ir nepareizs. Tā vietā izmantojiet kādu no šiem formātiem:%s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Ievadiet veselu skaitli." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" +"Pārliecinieties, ka šī vērtība ir mazāka vai vienāda ar %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" +"Pārliecinieties, ka šī vērtība ir lielāka vai vienāda ar %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\"vērtībai jābūt ar cipariem aiz komata." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Ievadiet skaitli." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Pārliecinieties, ka kopā nav vairāk par %s cipariem." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Pārliecinieties, ka nav vairāk par %s cipariem aiz komata." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Pārliecinieties, ka pirms komata nav vairāk par %s cipariem." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Netika iesniegts neviens fails. Pārbaudiet veidlapas kodēšanas veidu." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Netika iesniegts neviens fails." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Iesniegtais fails ir tukšs." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Pārliecinieties, vai šajā faila nosaukumā ir ne vairāk kā %(max)d rakstzīmes " +"(tam ir %(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" +"Lūdzu, iesniedziet failu vai atzīmējiet notīrīto izvēles rūtiņu, bet ne abus." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Augšupielādējiet derīgu attēlu. Augšupielādētais fails nebija attēls, vai " +"arī tas ir bojāts." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Bloķēts elements" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "Lapa nav 'pēdējā', un to nevar arī pārveidot par veselu skaitli (int)." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Nederīga lapa (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Nederīga atļaujas definēšana." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Nederīgs pk '%s' - objekts neeksistē." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Nepareizs tips. Sagaidītā pk vērtība, saņemta %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Objekts ar %s=%s neeksistē." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Nederīga hipersaite - nav sakritības ar URL" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Nederīga hipersaite - nepareiza URL sakritība" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Nederīga hipersaite konfigurācijas kļūdas dēļ" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Nederīga hipersaite - objekts neeksistē." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Nepareizs tips. Tika sagaidīts URL, saņemts %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Nederīgi dati" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Nav ievadīta informācija" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "Nevar izveidot jaunu vienumu, var atjaunināt tikai esošos vienumus." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Tiek sagaidīts vienumu saraksts." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Nav atrasts" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Atļauja/piekļuve liegta" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Servera lietotnes kļūda" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Savienojuma kļūda." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Nepareizi izveidots pieprasījums." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Nepareizi autentifikācijas rekvizīti." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Autentifikācijas rekvizīti netika iesniegti." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Jums nav atļaujas veikt šo darbību." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Metode '%s' nav atļauta." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Nevarēja apmierināt pieprasījuma Apstiprināšanas galveni" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Pieprasījumā ietverts neatbalstīts multivides tips '%s' ." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Pieprasījums ieķērās." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Paredzams, ka būs pieejams %d sekundē%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Pavisam negaidīta kļūda" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Nav atrasts." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Šim parametram (endpoint) metode netiek atbalstīta." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Nepareizi argumenti." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Datu validācijas kļūda" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Integritātes kļūda dēļ nepareiziem vai nederīgiem argumentiem" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Priekšnosacījuma kļūda" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Nav atlicis vietas priekš nākamajiem projektiem." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Kļūda filtru parametru tipos." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'projektam' jābūt veselai vērtībai (skaitlim)." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Jūs esat Taigatizēts" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

Laipni lūgtum " +"%(product_name)s, Atklātā koda, elastīgs Projektu vadības rīks

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" Iestatīt e-pasta paziņojumus " +"vai atrakstīties\n" +"  • \n" +" Taiga Atbalsts\n" +"  • \n" +" Sazināties\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Sekojiet mums Čiveklī (Twitterī)" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Iegūstiet kodu GitHubā" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] aktualizējumi" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Aktualizējumi" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

komentārs:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Komentārs: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Kļūda piekļūstot hostam" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "IP adreses kļūda" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "Vajadzība izveidota" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "Vajadzība mainīta" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "Vajadzība dzēsta" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "Vajadzība #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Uzdevums izveidots" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Uzdevums mainīts" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Uzdevums dzēsts" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Uzdevums #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Izmaiņu pieprasījums izveidots" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Izmaiņu pieprasījums mainīts" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Izmaiņu pieprasījums dzēsts" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Izmaiņu pieprasījums: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Wiki lapa izveidota" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Wiki lapa mainīta" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Wiki lapa dzēsta" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Wiki lapa: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Sprints izveidots" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Sprints mainīts" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Sprints dzēsts" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Sprints: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Mums bija vajadzīga vismaz viena loma" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Nepieciešams izmetes/kopijas (dump) fails" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Nederīgs izmetes formāts" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "Importējot projekta datus, gadījās ķibele" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "Importējot lomas, gadījās ķibele" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "Importējot dalībnieku dalības, gadījās ķibele" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "Importējot projekta atribūtu sarakstus, gadījās ķibele" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "kļūda importējot projekta atribūtu noklusējuma vērtības" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "Importējot pielāgotos atribūtus, gadījās ķibele" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "Importējot sprintus, gadījās ķibele" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "Importējot izmaiņu pieprasījumus, gadījās ķibele" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "Importējot vajadzības, gadījās ķibele" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "Importējot eposus, gadījās ķibele" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "Importējot uzdevumus, gadījās ķibele" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "Importējot wiki lapas, gadījās ķibele" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "Importējot wiki saites, gadījās ķibele" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "Importējot birkas, gadījās ķibele" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "Importējot laika grafikus, gadījās ķibele" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "Negaidīta dižķibele, importējot projektu" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Jums nevar būt vairāk privātu projektu" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" +"Šis projekts sasniedzis jūsu pašreizējo dalībnieku skaita limitu privātiem " +"projektiem" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Jums nevar būt vairāk publisku projektu" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" +"Šis projekts sasniedzis jūsu pašreizējo dalībnieku skaita limitu publiskiem " +"projektiem" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Veidojot projekta kopiju/dublikātu (dump), gadījās ķibele" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"Lietotājam {user_full_name} <{user_email}> ielādējot projekta kopiju (dump), " +"gadījās ķibele:\"\n" +"\n" +"\n" +"CĒLONIS:\n" +"-------\n" +"{reason}\n" +"\n" +"INFORMĀCIJA:\n" +"--------\n" +"{details}\n" +"\n" +"IZSEKOT KĻŪDU:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Ielādējot projekta kopiju, gadījās ķibele" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Ielādējot projekta kopijas (dump) failu, gadījās ķibele" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr " -- nav detalizētas informācijas --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Sveiki%(user)s,\n" +"\n" +"Kopija projektam%(project)s izveidota veiksmīgi. Lejuplādejama šeit:\n" +"\n" +"%(url)s\n" +"\n" +"Šie dati tiks dzēsti %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] Jūsu projekta kopija (dump) ir izveidota" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Sveicieni %(user)s,\n" +"\n" +"%(error_message)s\n" +"Projekta %(project)s exports.nav izdevies.\n" +"\n" +"Taigas sistēmas aprūpētāji ir informēti.\n" +"\n" +"Aicinām mēģināt vēlreiz. Ja ne, lūdzam sazināties ar atbalsta " +"grupu%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Sveicieni%(user)s,\n" +"\n" +"%(error_message)s\n" +" \n" +"Projekta imports neizdodadas.\n" +"\n" +"%(product_name)s sistēmas aprūpētāji ir informēti. Mēģiniet vēlreiz, lūdzu. " +"Ja neizdodas, aicinam sazināties ar atbalsta grupu:\n" +"\n" +" %(support_email)s \n" +" %(signature)s \n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Projekta kopija importēta

\n" +"

Sveicināti %(user)s,

\n" +"

Jūsu projekts importēts veiksmīgi.

\n" +" Dodieties uz %(project)s\n" +"

%(signature)sT

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Sveicieni %(user)s,\n" +"\n" +"Projekta damps importēts veiksmīgi.\n" +"\n" +"Projekts apskatāms %(project)s šeit:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Jūsu projekta kopija ir importēta" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" netika atrasts šajā projektā" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Nederīgs saturs. Tam jābūt {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "Tas satur nederīgus pielāgotos laukus." + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Nepieciešama autentifikācija" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "vārds" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "ikonas URL" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "tīmekļa URL" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "apraksts" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "nākamais URL" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "pieteikums" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Nederīgs tokens" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "pilns vārds" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "e-pasta adrese" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "komentārs" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "izveides datums" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Atsauksmes

\n" +"

Taiga saņēmusi atsauksmi no %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Komentārs

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Papildus informācija" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- No: %(full_name)s <%(email)s>\n" +"---------\n" +"- Komentārs:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Papildus informācija:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Atsauksme no %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "Pielikums (payload) nav derīgs json" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Projekts neeksistē" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Slikts paraksts" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"Komentārs no {platform}:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Nederīga izmaiņu pieprasījuma komentāra informācija" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Izmaiņas pieprasījums izveidots no {platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Nederīga izmaiņas pieprasījuma informācija" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "nezināms lietotājs" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} mainīja statusu no [{platform} commit]({commit_url} \"Skat. " +"kommitu '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Statuss: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"Mainīts statuss no {platform} kommita.\n" +"\n" +" - Statuss: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"Šo {type_name} pieminēja lietotājs {user_text} iekš [{platform} commit]" +"({commit_url} \"Skat. kommitu '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" +"Šis izmaiņas pieprasījums bijis minēts {platform} commit \"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Norādītais elements neeksistē" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Statuss nepastāv" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Nepieciešams projekta parametrs" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Nederīgs Asana API pieprasījums" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "Neizdevās veikt pieprasījumu Asana APIm" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Nepieciešams koda parametrs" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Importējot Asana projektu, gadījās ķibele" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Nederīgi autentifikācijas dati" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "Trešās puses pakalpojums pienācīgi nedarbojas" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "Importējot GitHub projekta, gadījās ķibele" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "Nepieciešams URL parametrs" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "Nederīgs projekta_tips {}" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "Nekorekta Jira servera konfigurācija." + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "Autorizācijas tokens nederīgs vai ar beigušos derīguma termiņu" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Importējot Jira projektu, gadījās ķibele" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "Importējot PivotalTracker projektu, gadījās ķibele" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Asana projekts importēts

\n" +"

Sveicināti %(user)s,

\n" +"

Jūsu Asana projekts tika sekmīgi importēts.

\n" +" Doties uz %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, fuzzy, python-format +#| msgid "Invalid Request: %s at %s" +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "Nederīgs vaicājums: %s pie %s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, fuzzy, python-format +#| msgid "Unauthorized: %s at %s" +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "Neautorizēts: %s pie %s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, fuzzy, python-format +#| msgid "Resource Unavailable: %s at %s" +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "Resurss nav pieejams: %s pie %s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Apskatīt projektu" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Apskatīt ceļa atzīmes" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Apskatīt Eposu" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Apskatīt vajadzības" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Skatīt uzdevumus" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Apskatīt izmaiņu pieteikumus" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Apskatīt Wiki lapas" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Apskatīt Wiki saites" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Pievienot ceļa atzīmi" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Mainīt ceļa atzīmi" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Dēst ceļa atzīmi" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Radīt Eposu" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Mainīt Eposu" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "Komentēt Eposu" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Dzēst Eposu" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Apskatīt vajadzību" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Pievienot vajadzību" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Mainīt vajadzību" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "Komentēt vajadzību" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Dzēst vajadzību" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Izveidot uzdevumu" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Mainīt uzdevumu" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Komentēt uzdevumu" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Dzēst uzdevumu" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Izveidot izmaiņas pieprasījumu" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Mainīt izmaiņu pieprasījumu" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "Komentēt izmaiņu pieprasījumu" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Dzēst izmaiņas pieprasījum" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Pievienot Wiki lapu" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Mainīt Wiki lapu" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Komentēt Wiki lapu" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Dzēst Wiki lapu" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Pievienot Wiki saiti" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Mainīt Wiki saiti" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Dzēst Wiki saiti" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Mainīt projektu" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Dzēst projektu" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "+ Jauns dalībnieks" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Dzēst dalībnieku" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Admin lomas" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "Privātums" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "Moduļi" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Noklusētās vērtības" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "Darbība" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "Fani" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "Vadītājs" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Padarīt publisku" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} sekmīgi padarīts publisks." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Padarīt privātu" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} sekmīgi padarīts privāts." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Nepietiekoši argumenti." + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Nederīgs attēla formāts" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "Sagataves nosaukums nav derīgs" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "Tāds lietotājs nepastāv" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "Šis lietotājs jau ir projekta dalībnieks." + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "comment is required" +msgid "Email is required" +msgstr "komentārs obligāts" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "projekts" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "ir novecojis/netiek izmantots" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "no komentāra" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "pasūtīt" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "Whereby.com" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Pielāgojams" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky.io" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "Šis projekts ir bloķēts nesaņemta maksājuma dēļ" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "Šo projektu bloķējis kāds no administrējošā personāla" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "Šis projekts ir bloķēts dēļ tā, ka vadītājs to pametis" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "Šis projekts ir bloķēts kamēr tas ir dzēsts" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +" %(full_name)s ir " +"uzrakstījis %(project_name)s\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +" Jūs saņemat šo ziņu, jo esat norādīts kā administrators projektam ar " +"nosaukumu %(project_name)s. Ja nevēlaties, lai Taigas kopienas locekļi " +"sazinātos ar jūsu projektu, lūdzu aktualizējiet projekta iestatījumus, " +"lai turpmāk novērstu šādus kontaktus. Projekta dalībnieku ikdienišķo saziņu " +"tas neietekmēs." + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s ir uzrakstījis %(project_name)s\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +"Jūs saņemat šo ziņu, jo esat norādīts kā administrators projektam ar " +"nosaukumu %(project_name)s.Ja nevēlaties, lai Taigas kopienas locekļi " +"sazinātos ar jūsu projektu, lūdzu aktualizējiet projekta iestatījumus iekš " +"%(project_settings_url)s, lai turpmāk novērstu šādus kontaktus. Projekta " +"dalībnieku ikdienišķo saziņu tas neietekmēs.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s ir atsūtījis ziņu projektam %(project_name)s\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Teksts" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Daudzrindu teksts" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "Bagātīgi formatēts teksts (RTF)" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Datums" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "URL" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "Nokarena izvēlne" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "Rūtiņa" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "Skaits" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "tips" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "vērtības" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "eposs" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "vajadzība" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "uzdevums" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "izmaiņas pieprasījums" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Viena ar šādu nosaukumu jau pastāv." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "termiņš" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "Pamatojums termiņam" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "Jums nav atļauju/tiesību iestatīt šo statusu šim eposam" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "atsauce" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "statuss" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "eposu kārtība" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "priekšmets" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "krāsa" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "piešķirts/atbildīgais" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "ir klienta prasība" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "ir komandas prasība" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "vajadzības " + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "ārējā atsauce" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "Nav eposa ar šādu ID" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "komentārs obligāts" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "dzēsti komentāri nevar tikt rediģēti" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Komentārs jau dzēsts" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Komentārs nav dzēsts" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Izmainīt" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Izveidot" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Dzēst" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s lomas punkti" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "no" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "uz" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Pievienots jauns pielikums" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Pielikums aktualizēts" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "novecojis" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "nenovecojis/aktuāls" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Dzēsts pielikums" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "pievienots" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "noņemts" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Nepiešķirts" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "Neiestatīts" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-dzēsts-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "kam:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "no:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Pievienots" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Mainīts" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Dzēsts" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "pievienots:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "noņemts:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "No:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Kam:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "saturs" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "bloķēta piezīme" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "sprints" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Jums nav atļauju iestatīt šo sprintu šim izmaiņu pieprasījumam." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Jums nav atļauju iestatīt šādu statusu šim izmaiņu pieprasījumam." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "Jums nav atļauju iestatīt šādu nozīmīgumu šim izmaiņu pieprasījumam." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "Jums nav atļauju iestatīt šādu prioritāti šim izmaiņu pieprasījumam." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Jums nav atļauju iestatīt šādu tipu šim izmaiņu pieprasījumam." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "nozīmīgums" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "prioritāte" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "robežzīme" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "beigšanas datums" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "Šī robežzīme šim projektam neder" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "Visiem izmaiņu pieprasījumiem jābūt no viena un tā paša projekta" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Tīk" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Patikumi (laiki)" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "lode" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "paredzētais sākuma datums" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "paredzētais beigu datums" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "ir slēgts/beigts" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "disponējamība" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "Paredzamajam sākumam jābūt pirms paredzētajām beigām." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "Nav atskaites punkta ar šādu id" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "Visām vajadzībām jābūt no viena un tā paša projekta" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "ir bloķēts" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "nepieciešams atsauces parametrs" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "ir nepieciešams projekts vai projekta__slug parametrs" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' parametrs ir obligāts" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' parametrs ir obligāts" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "Lietotājam jābūt projekta dalībniekam." + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "e-pasts" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "izveidot" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "tokens/žetons" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "ielūguma papildus teksts" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "lietotāja pasūtījums" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "Šis lietotājs jau ir šī projekta dalībnieks" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "eposa statuss pēc noklusējuma" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "vajadzības statuss pēc noklusējuma" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "punkti pēc noklusējuma" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "uzdevuma statuss pēc noklusējuma" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "prioritāte pēc noklusējuma" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "nozīmīgums pēc noklusējuma" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "izmaiņas pieprasījuma statuss pēc noklusējuma" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "izmaiņas pieprasījuma tips pēc noklusējuma" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "logotips" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "dalībnieki" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "robežzīmju kopējais skaits" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "vajadzību punktu kopējais skaits" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "aktīvs kontakts" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "aktīvais eposa panelis" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "aktīvais darāmo darbu (backlog) panelis" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "aktīvais kanban panelis" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "aktīvais wiki panelis" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "aktīvais izmaiņu pieprasījuma panelis" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "videokonferences sistēma" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "videokonferences papildus dati" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "izveidošanas šablons" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "ir privāts" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "anonīmā lietotāja atļaujas" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "lietotāja atļaujas" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "ir attēlots" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "vēlas piesaistīt cilvēkus" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "cilvēku piesaistes piezīme" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "projekta nodošanas tokens/pilnvara" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "bloķēts kods" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "aktualizēts datums laiks" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "skaits" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "fani pagājušajā nedēļā" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "fani pagājušajā mēnesī" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "fani pagājušajā gadā" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "darbības pagājušajā nedēļā" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "darbības pagājušajā mēnesī" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "darbības pagājušajā gadā" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "moduļu konfigurācija" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "ir arhivēts" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "nepabeigto darbu limits" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "vērtība" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "pēc noklusējuma" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "dienas līdz termiņa beigām" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "vadītāja loma pēc noklusējuma" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "opcijas pēc noklusējuma" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "eposa statusi" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "vajadzību statusi" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "vajadzību termiņi" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "punkti" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "uzdevumu statusi" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "uzdevumu termiņi" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "izmaiņu pieprasījumu statusi" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "izmaiņu pieprasījumu tipi" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "izmaiņu pieprasījumu termiņi" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "prioritātes" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "nozīmīguma pakāpes" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "lomas" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "eposa pielāgojamie atribūti" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "vajadzības pielāgojamie atribūti" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "uzdevuma pielāgojamie atribūti" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "izmaiņas pieprasījuma pielāgojamie atribūti" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Iesaistīts" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Visi" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Nav/Neviens" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "Piešķirts" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "Minēts" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "Pievienots kā vērotājs" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "Pievienots kā dalībnieks" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "Komentārs" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "Minēts komentārā" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "izveidošanas datums laiks" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "paziņot lietotājiem" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Vērots" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Paziņojums attiecas uz norādīto lietotāju un projektu" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Paziņojuma līmeņa vērtība nav derīga" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Vērotāju vidū ir nederīgi lietotāji" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "Versijai jābūt veselam skaitlim" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "Versijas parametrs nav derīgs" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Versija nesakrīt ar pašreizējo" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "versija" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"Jūs nevarat pamest projektu, ja esat tā vadītājs vai projektam nav citu " +"administratoru" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "Tokens neatbilst nevienam derīgam uzaicinājumam." + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The user doesn't exist" +msgid "User does not exist." +msgstr "Tāds lietotājs nepastāv" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Šis lietotājs jau ir projekta dalībnieks." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "jauna e-pasta adrese" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Projekts bez vadītāja" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" +"Jūs esat sasniedzis pašreizējo dalībnieku skaita limitu privātiem projektiem" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" +"Jūs esat sasniedzis pašreizējo dalībnieku skaita limitu publiskiem projektiem" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "Jūs esat sasniedzis izskatāmo potenciālo dalībnieku pašreizējo limitu" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Nākotnes sprints" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Projekta beigas" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Nederīgs tokens" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Novecojis/izlietots tokens" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "Eposi" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "Darba klāsts" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanbans" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "Izmaiņu pieprasījumi" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "TeamWiki" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "Jums nav piekļuves šai sadaļai" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "Nederīga birka “{value}”. Krāsa nav derīga HEX krāsa vai ir nulle." + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"Nederīga birka “{value}”. Tai jābūt vārdam vai pārim '[\"nosaukums\", \"HEX " +"krāsa /\" | nulle]'." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "Nederīga birka “{value}”. Tam jābūt birkas nosaukumam." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "birkas" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "birku krāsas" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Šāda birka jau pastāv" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "Šī nav derīga HEX krāsa." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "Birka neeksistē." + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "Jums nav atļauju iestatīt šo sprintu šim uzdevumam." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "Jums nav atļauju iestatīt šo vajadzību šim uzdevumam." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "Jums nav atļauju iestatīt šo statusu šim uzdevumam." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "vajadzības pasūtījums" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "Nederīgs starpposma id." + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "Nederīgs uzdevuma statusa id." + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "Nederīgs vajadzības id." + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" +"Nederīgs uzdevuma statusa id. Statusam jāpieder vienam un tam pašam " +"projektam." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "kāds" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Pieņemt uzaicinājumu uz Taigu" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Pieņemt uzaicinājumu" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "Pieprasīt nodot citai personai" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s saka:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"Ja vēlaties, joprojām varat mēģināt nodot projekta vadību citai personai.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "Pieprasīt nodot citai personai:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Turpināt" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"Ja vēlaties sākt projekta nodošanu no administrēšanas paneļa, lūdzu, " +"dodieties uz sava projekta iestatījumiem.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Atveriet sava projekta iestatījumus:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "Akceptēt vai noraidīt projekta vadības nodošanu:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Skram" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Skram spējo produkta darbu klāsts ir prioritarizēts funkcionalitāšu/iespēju " +"saraksts, kurā ir īsi visu produktā/izstrādājumā vēlamo funkcionalitāšu " +"apraksti. Izmantojot Skram, nav nepieciešams sākt projektu ar ilgstošām, " +"sākotnējām pūlēm dokumentēt visas prasības. Pēc tam, kad vairāk tiek " +"noskaidrots par produktu un tā klientiem, Skram darbu klāstam tiek ļauts " +"augt un mainīties. " + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban ir metode zināšanu darba vadīšanai, uzsvaru liekot uz piegādi tieši " +"laikā, vienlaikus nepārslogojot komandas locekļus. Šajā pieejā dalībniekiem " +"ir redzams process, sākot no uzdevuma definēšanas līdz tā rezultāta " +"nogādāšanai klientam, un komandas locekļi \"paņem\" darbu no rindas." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Jauns" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Gatavs" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "Darbībā/norisē/aktīvs" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Gatavs testēšanai/pārbaudei/pārbaudāms" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Gatavs" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Arhivēts" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Slēgts" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Gaida info / info gaidās" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Atlikts" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Noraidīts" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Kļūda" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Jautājums" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Uzlabojums" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Zems" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Vidējs" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Augsts" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Vēlmju saraksts" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Nepilngadīgais" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Svarīgs" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Kritisks" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Dizains" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Priekšiņas" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Iepakaļas" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Projekta vadītājs" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Ieinteresētā persona" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "Jums nav atļauju iestatīt šo sprintu šai vajadzībai." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "Jums nav atļauju iestatīt šo statusu šai vajadzībai." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "Nederīgs lomas ID '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "Nederīgs punktu ID '{points_id}'" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Vajadzības ģenerēšana #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "loma" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "darba klāsta pasūtījums" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "sprinta pasūtījums" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "kanban pasūtījums" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "beigu datums" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "atbildīgie lietotāji" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "ģenerēts no izmaiņu pieprasījuma" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "ģenerēts no uzdevuma" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Ar šo ID nav nevienas vajadzības" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"Nederīgs vajadzības statusa ID. Statusam jābūt saistītam ar vienu un to pašu " +"projektu." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +#, fuzzy +#| msgid "" +#| "Invalid user story status id. The status must belong to the same project." +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"Nederīgs vajadzības statusa ID. Statusam jābūt saistītam ar vienu un to pašu " +"projektu." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +#, fuzzy +#| msgid "" +#| "Invalid user story status id. The status must belong to the same project." +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"Nederīgs vajadzības statusa ID. Statusam jābūt saistītam ar vienu un to pašu " +"projektu." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Nav neviena projekta ar šādu ID" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "Lietotājs projektā vēl/joprojām ir/pastāv" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Projektam šī loma nav derīga" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "Lietotājam jābūt derīgai kontaktpersonai" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "Projekta vadītājam jābūt arī administratoram." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "Vismaz vienam lietotājam jābūt aktīvam šī projekta administratoram." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "Nederīgi lomu id. Visām lomām jāpieder vienam un tam pašam projektam." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Opcijas pēc noklusējuma" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Vajadzību statusi" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Punkti" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Uzdevumu statusi" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Izmaiņu pieprasījumu statusi" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Izmaiņu pieprasījumu tipi" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Prioritātes" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Nozīmīguma pakāpes" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Lomas" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Balsis" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Balss" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "“satura” parametrs ir obligāts" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "parametrs 'project_id' ir obligāts" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "pēdējais rediģējis" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Pārbaudiet vēstures API, lai iegūtu precīzu atšķirību (diff)" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Projekta dalībnieks" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Projekta dalībnieki" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Projekta vadītājs" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Projekta vadītāji" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "dalībnieki" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Projekta beigas" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Projekta beigas" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Dublēts e-pasts" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "Nederīgs e-pasts" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Nederīgs lietotājvārds vai e-pasts" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Nepieciešams pašreizējais paroles parametrs" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Nepieciešams jauns paroles parametrs" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Nederīga pašreizējā parole" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Nederīgs. Vai esat pārliecināts, ka tokens ir pareizs, un iepriekš nav " +"izmantots?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Nederīgs. Vai esat pārliecināts, ka tokens ir pareizs?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "Pasts nosūtīts sekmīgi!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "superlietotāja statuss" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "Apzīmē, ka šim lietotājam ir visas atļaujas, tieši tās nepiešķirot." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "lietotājvārds" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "Obligāti. 30 rakstzīmes vai mazāk. Burti, cipari un /./-/_ rakstzīmes" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Ievadiet derīgu lietotājvārdu." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "aktīvs" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Apzīmē, vai šis lietotājs jāuzskata par aktīvu. Kontu dzēšanas vietā " +"noņemiet šo atzīmi." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biogrāfija" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "attēls/foto" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "pievienošanās datums" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "pievienošanās datums" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "akceptētie noteikumi" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "jaunie noteikumi izlasīti" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "noklusētā valoda" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "noklusētā ādiņa" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "noklusētā laika josla" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "iekrāsot birkas" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "e-pasta tokens" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "jauna e-pasta adrese" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "maksimālais vadīto privāto projektu skaits" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "maksimālais vadīto publisko projektu skaits" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "maksimālais dalībnieku skaits katrā vadītajā privātajā projektā" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "maksimālais dalībnieku skaits katrā vadītajā publiskajā projektā" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "atļaujas" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Lietotājvārds vai parole neatbilst lietotājam." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Nomainīt e-pastu" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Paroles atgūšana" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Jūs esat Taigatizēts" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "nederīgs" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Nederīgs lietotājvārds. Mēģiniet citu." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "\"Izlasīti jaunie noteikumi\" jābūt atzīmētiem ar 'patiesība'" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"Duplicēta/atkartojošās atslēgas vērtība pārkāpj unikālo ierobežojumu. " +"Atslēga '{}' jau pastāv." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "atslēga" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "slepenā atslēga" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "statusa kods" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "pieprasīt datus" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "pieprasīt galvenes" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "atbildes dati" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "atbildes galvenes" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "ilgums" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "Neatļauta IP adrese" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "" +#~ "Nederīgs robežzīmes ID. Robežzīmei jābūt saistītai ar vienu un to pašu " +#~ "projektu." + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "" +#~ "Nederīgi vajadzību ID. Visām vajadzībām jābūt saistītām ar vienu un to " +#~ "pašu projektu, un, ja tas eksistē, ar to pašu statusu un robežzīmi." + +#~ msgid "Personal info" +#~ msgstr "Personiskā informācija" + +#~ msgid "Permissions" +#~ msgstr "Atļaujas" + +#~ msgid "Restrictions" +#~ msgstr "Ierobežojumi" + +#~ msgid "Important dates" +#~ msgstr "Svarīgi datumi" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/nb/LC_MESSAGES/django.po b/taiga/locale/nb/LC_MESSAGES/django.po new file mode 100644 index 000000000..51a96b89d --- /dev/null +++ b/taiga/locale/nb/LC_MESSAGES/django.po @@ -0,0 +1,4886 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Jørgen Skår Fischer , 2016 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2024-03-28 12:02+0000\n" +"Last-Translator: Allan Nordhøy \n" +"Language-Team: Norwegian Bokmål \n" +"Language: nb\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.5-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "ugyldig påloggingstype" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "Ikke funnet" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "Ugyldig rolle for prosjektet" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "Token er ugyldig" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "ugyldig brukernavn" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Påkrevd. 255 tegn eller færre. Bokstaver, tall og /./-/_ tegn '" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Brukernavnet er allerede i bruk." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "E-postadressen er allerede i bruk." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Brukeren er allerede registrert." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "bruker" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "Token er ugyldig" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no id" +msgstr "Token er utløpt" + +#: taiga/auth/tokens.py:138 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no type" +msgstr "Token er utløpt" + +#: taiga/auth/tokens.py:141 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has wrong type" +msgstr "Token er utløpt" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +#, fuzzy +#| msgid "Token has expired" +msgid "Token '{}' claim has expired" +msgstr "Token er utløpt" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Token er ugyldig" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Dette feltet er obligatorisk." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Ugyldig verdi." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' verdi må være enten 'True' eller 'False'" + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Skriv inn en gyldig 'slug' bestående av bokstaver, tall, understreker eller " +"bindestreker. " + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "Gjør et gyldig valg. %(value)s er ikke et av de tilgjengelige valgene." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Skriv inn en gyldig e-postadresse." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "Datoen har feil format. Bruk en av disse formatene istedet: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "Datotid har feil format. Bruk en av disse formatene istedet: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "Tid har feil format. Bruk en av disse formatene istedet: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Skriv inn et heltall." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Sikre at denne verdien er mindre enn eller lik %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Sikre at denne verdien er større enn eller lik %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" verdi må være et desimaltall." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Skriv inn et nummer." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Pass på at det ikke er flere enn %s sifre totalt." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Pass på at det ikke er flere enn %s desimaler." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Pass på at det ikke er flere enn %s siffer før komma." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Ingen fil ble sendt. Kontroller kodingstypen på skjemaet." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Ingen fil ble sendt." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Den sendte filen er tom." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Sikre at dette filnavnet har på det meste %(max)d tegn (det har %(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" +"Vennligst enten send inn en fil eller sjekk den klare sjekkboksen, ikke " +"begge deler." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Last opp et gyldig bilde. Filen du lastet opp var enten ikke et bilde eller " +"et ødelagt bilde." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Blokkert element" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" +"Siden er ikke 'sist', og den kan heller ikke konverteres til en integer." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Ugyldig side (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Ugyldig tilgangsdefinisjon." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Ugyldig pk '%s' - objektet eksisterer ikke." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Feil type. Forventet \"pk\" verdi, mottok %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Objekt med %s=%s eksisterer ikke" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Ugyldig " + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Ugyldig hyperkobling - Feil URL" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Ugyldig hyperkobling på grunn av konfigurasjonsfeil" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Ugyldig hyperkobling - objekt finnes ikke." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Feil type. Forventet url streng, fikk %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Ugyldig data." + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Ingen inndata ble angitt" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Kan ikke opprette et nytt element, kun eksisterende elementer kan oppdateres." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Forventet en liste med elementer." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Ikke funnet" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Tilgang nektet" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Server programfeil" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Tilkoblingsfeil" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Uriktig formatert forespørsel" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Feil godkjenningsinformasjon." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Autentiseringsopplysninger ble ikke gitt." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Du har ikke tillatelse til å utføre denne handlingen." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Metode '%s' ikke tillatt." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Kunne ikke tilfredsstille forespørselens 'Accept header'" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Uegnet medietype '%s' i forespørselen." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Forespørselen ble strupet." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Forventet tilgjengelig om %d second%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Uventet feil" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Ikke funnet." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Metode ikke støttet for dette endepunktet ." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Feil argumenter." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Data valideringsfeil" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Integritetsfeil for gale eller ugyldige argumenter" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Forutsetningsfeil" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Ingen plass igjen til nye prosjekter." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Feil i filterparameter typer" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project' må være et heltall" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Du har blitt Taigatisert" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Følg oss på Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Skaff koden på GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Oppdateringer" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Oppdateringer" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

kommentar:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Kommentar: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Vi trenger minst en rolle" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Har behov for dump-fil" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Ugyldig fil-dump format" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "feil under import av prosjektdata" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "feil under import av roller" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "feil under import av medlemskap" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "feil under import av prosjektegenskaper" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "feil under import av egendefinerte egenskaper" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "feil under import av sprinter" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "feil ved import av hendelser" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "feil ved import av brukerhistorier" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "feil ved import av oppgaver" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "feil ved import av wiki-sider" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "feil ved import av wiki-lenker" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "feil ved import av etiketter" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "feil ved import av tidslinjer" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "uventet feil ved import av prosjekt" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Du kan ikke ha fler private prosjekter" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" +"Dette prosjektet kommer til å nå din nåværende grense for medlemskap for " +"private prosjekter" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Du kan ikke ha flere offentlige prosjekter" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" +"Dette prosjektet kommer til å nå din nåværende grense for medlemskap for " +"offentlige prosjekter" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Feil ved generering av prosjektet dump" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Feil ved lasting av prosjektet dump" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Feil ved lasting av din prosjektdump-fil" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "-- ingen detaljeinfo --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" ble ikke funnet i dette prosjektet" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Ugyldig innhold. Det må være {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Autentisering kreves" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "navn" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "Ikon url" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "beskrivelse" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Neste url" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "applikasjon" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Ugyldig polett" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "fullt navn" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "e-postadresse" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "kommentar" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "opprettet dato" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Tilbakemelding

\n" +"

Taiga har mottatt tilbakemelding fra %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Kommentar

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Ekstra info" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- Fra: %(full_name)s <%(email)s>\n" +"---------\n" +"- Kommentar:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Ekstra info: " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Tilbakemelding fra %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Prosjektet eksisterer ikke" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Dårlig signatur" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Ugyldig hendelsesinformasjon" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Det refererte elementet finnes ikke" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Statusen eksisterer ikke" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Vis prosjekt" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Vis milepæler" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Vis brukerhistorier" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Vis oppgaver" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Vis hendelser" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Se wiki-sider" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Se wiki-lenker" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Legg til milepæl" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Endre milepæl" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Slett milepæl" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Vis brukerhistorie" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Legg til brukerhistorie" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Rediger brukerhistorie" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Slett brukerhistorie" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Legg til oppgave" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Rediger oppgave" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Slett oppgave" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Legg til hendelse" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Rediger hendelse" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Slett hendelse" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Legg til wiki-side" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Endre wiki-side" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Slett wiki-side" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Legg til wiki-lenke" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Endre wiki-lenke" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Slett wiki-lenke" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Rediger prosjekt" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Slett prosjekt" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Legg til medlem" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Fjern medlem" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Admin prosjektverdier" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Admin roller" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "eier" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Ufullstendige argumenter" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Ugyldig bildeformat" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "Ugyldig brukerid" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "Brukeren eksisterer ikke" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "Dette feltet er obligatorisk." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"Prosjektet må ha en eier og minst en av brukerne må være en aktiv " +"administrator" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "Delvis oppdateringer støttes ikke" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "prosjekt" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "innholdstype" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "objektid" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "redigeringsdato" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "vedlagt fil" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "er foreldet" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "rekkefølge" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Egendefinert" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "Dette prosjektet er blokkert på grunn av manglende betaling" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "Dette prosjektet er blokkert av en administrator" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "Dette prosjektet er blokkert fordi eieren stakk" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Tekst" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Tekst med flere linjer" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Dato" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "type" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "verdier" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "brukerhistorie" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "oppgave" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "hendelse" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Det finnes allerede en med samme navn." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "status" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "subjekt" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "farge" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "tildelt til" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "Er klientkrav" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "Er team behov" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "ekstern referanse" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Kommentaren er allerede slettet" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Kommentaren er ikke slettet" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Endre" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Opprett" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Slett" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s rollepoeng" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "fra" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "til" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "La til nytt vedlegg" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Oppdatert vedlegg" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "foreldet" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "ikke foreldet" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Slettede vedlegg" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "lagt til" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "fjernet" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Ikke tildelt" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-slettet-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "til:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "fra:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Lagt til" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Endret" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Slettet" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "lagt til: " + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "fjernet: " + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Fra: " + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Til: " + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "innhold" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "blokkert notat" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "sprint" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Du har ikke tillatelse til å sette denne sprinten til denne hendelsen." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Du har ikke tillatelse til å sette denne statusen til denne hendelsen." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" +"Du har ikke tillatelse til å sette denne alvorlighetsgraden til denne " +"hendelsen." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" +"Du har ikke tillatelse til å sette denne prioriteten til denne hendelsen" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Du har ikke tillatelse til å sette denne typen til denne hendelsen." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "alvorlighetsgrad" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "prioritet" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "milepæl" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "Sluttdato" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Liker" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Liker" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "slug" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "anslått startdato" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "anslått sluttdato" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "er lukket" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "er blokkert" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' parameter er obligatorisk" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' parameter er obligatorisk" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "e-post" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "opprett ved" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "token" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "invitasjon ekstra tekst" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "bruker rekkefølge" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "Denne brukeren er allerede medlem av prosjektet" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "standard brukerhistoriestatuser" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "standardpoeng" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "standard oppgavestatuser" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "standard prioriteter" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "standard alvorlighetsgrad" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "standard hendelsesstatuser" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "standard hendelsestyper" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "logo" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "medlemmer" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "total av milepæler" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "total historiepoeng" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "aktivt backlogpanel" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "aktivt kanbanpanel" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "aktivt wikipanel" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "aktivt hendelsespanel" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "videokonferansesystem" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "videokonferanse ekstra data" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "skapelsesmal" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "er privat" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "anonymes rettigheter" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "brukerrettigheter" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "er omtalt" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "er søker etter folk" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "prosjektflyttingstoken" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "blokkert kode" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "oppdatert dato tid" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "antall" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "fans forrige uke" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "fans forrige måned" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "fans forrige år" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "aktivitet forrige uke" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "aktivitet forrige måned" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "aktivitet forrige år" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "modulkonfigurasjon" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "er arkivert" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "arbeid som pågår grense" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "verdi" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "standard eiers rolle" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "standardvalg" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "bh statuser" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "poeng" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "oppgavestatuser" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "hendelsesstatuser" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "hendelsestyper" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "prioriteter" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "alvorlighetsgrader" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "roller" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Involvert" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Alle" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Ingen" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "opprettet dato tid" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "loggoppføringer" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "varsle brukere" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Fulgt" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Ugyldig verdi for varslingsnivå" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Følgere inneholder ugyldige brukere" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "Versjonen må være et heltall" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "Versjonsparameteret er ikke gyldig" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Versjonen samsvarer ikke med den nåværende" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "versjon" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"Du kan ikke forlate prosjektet hvis du er eieren eller det ikke er flere " +"administratorer" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The user doesn't exist" +msgid "User does not exist." +msgstr "Brukeren eksisterer ikke" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Denne brukeren er allerede et medlem i prosjektet." + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "Feilaktig e-postadresse." + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "Du har nådd din nåværende grense for medlemskap for private prosjekter" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" +"Du har nådd din nåværende grense for medlemskap for offentlige prosjekter" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Fremtidig sprint" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Prosjektslutt" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Token er ugyldig" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Token er utløpt" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "etiketter" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "etiketter farge" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "Du har ikke tillatelse til å sette denne sprinten til denne oppgaven." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" +"Du har ikke tillatelse til å sette denne brukerhistorien til denne oppgaven." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "Du har ikke tillatelse til å sette denne statusen til denne oppgaven." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "BH rekkefølge" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "Oppgavetavle rekkefølge" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "Er Iocaine" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "noen" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Godta invitasjonen til Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Godta din invitasjon" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Fortsett" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Ny" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Klar" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "Under arbeid" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Klar til test" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Ferdig" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Arkivert" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Lukket" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Trenger info" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Utsatt" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Avslått" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Bug" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Spørsmål" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Forbedring" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Lav" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normal" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Høy" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Ønskeliste" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Liten" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Viktig" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Kritisk" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Design" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Front" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Back" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Produkteier" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Interessent" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"Du har ikke tillatelse til å sette denne sprinten til denne brukerhistorien." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" +"Du har ikke tillatelse til å sette denne statusen til denne brukerhistorien." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Genererer brukerhistorien #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "rolle" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "backlog rekkefølge" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "sprint rekkefølge" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "Sluttdato" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Det finnes ingen brukerhistorie med den id'en" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Det finnes ikke noe prosjekt med den id'en" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Ugyldig rolle for prosjektet" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "Prosjekteieren skal være admin." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "Minst en bruker må være en aktiv administrator for dette prosjektet." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Standardvalgene" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Brukerhistoriestatuser" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Poeng" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Oppgavestatuser" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Hendelsesstatuser" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Hendelsestyper" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Prioriteter" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Alvorlighetsgrad" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Roller" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Stemmer" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Stemme" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content' parameteren er obligatorisk" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id' parameteren er obligatorisk" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "sist endret av" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Sjekk historieAPI'et for den eksakte forskjellen" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Prosjektmedlem" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Prosjektmedlemmer" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Prosjekteierskap" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Prosjekteierskap" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "medlemmer" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Prosjektslutt" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Prosjektslutt" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Duplikat e-post" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Ugyldig brukernavn eller e-postadresse" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Nåværende passord er nødvendig" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Nytt passord er nødvendig" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Ugyldig nåværende passord" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Ugyldig, er du sikker på at token er korrekt og at du ikke har brukt den før?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Ugyldig, er du sikker på at token er korrekt?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "E-post sendt!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "superbrukerstatus" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Angir at denne brukeren har alle tillatelser uten eksplisitt tildele dem." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "brukernavn" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "Påkrevd. 30 tegn eller færre. Bokstaver, tall og /./-/_ tegn" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Skriv inn et gyldig brukernavn" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "aktiv" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Betegner om denne brukeren bør behandles som aktiv. Velg bort dette i stedet " +"for å slette kontoer." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografi" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "bilde" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "dato ble med" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "dato ble med" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "standardspråk" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "standard tema" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "standard tidssone" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "fargelegg etiketter" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "e-postsymbol" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "ny e-postadresse" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "maks antall eide private prosjekter" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "maks antall eide offentlige prosjekter" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "maks antall medlemskap for hvert eide private prosjekt" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "maks antall medlemskap for hvetr eide offentlige prosjekt" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "rettigheter" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Brukernavn eller passord passer ikke til brukeren." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Endre e-postadresse" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Du kan fjerne din konto fra denne tjenesten: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Du har blitt Taigatisert!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "ugyldig" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Ugyldig brukernavn. Prøv med et annet et." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"Duplicate nøkkelverdi bryter unik begrensning. Nøkkelen \"{}\" finnes " +"allerede." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "nøkkel" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "hemmelig nøkkel" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "statuskode" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "forespørselsdata" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "varighet" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Personal info" +#~ msgstr "Personlig informasjon" + +#~ msgid "Permissions" +#~ msgstr "Tilganger" + +#~ msgid "Restrictions" +#~ msgstr "Restriksjoner" + +#~ msgid "Important dates" +#~ msgstr "Viktige datoer" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/nl/LC_MESSAGES/django.po b/taiga/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 000000000..89ca174fb --- /dev/null +++ b/taiga/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,4920 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Dajo Hein, 2015 +# Dajo Hein, 2015 +# Haroun Pacquee, 2015 +# Haroun Pacquee, 2015 +# Joannes Anthonius Rommers , 2018 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2023-11-23 17:05+0000\n" +"Last-Translator: Ranforingus \n" +"Language-Team: Dutch \n" +"Language: nl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.2.1-rc\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "ongeldig login type" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "U moet onze servicevoorwaarden en ons privacybeleid accepteren" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "Niet gevonden" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "Ongeldige rol voor project" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "Token is ongeldig" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "ongeldige gebruikersnaam" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Verplicht. 255 tekens of minder. Letters, nummers en /./-/_ tekens'" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Gebruikersnaame is al in gebruik." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "E-mail adres is al in gebruik." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Gebruiker is al geregistreerd." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "gebruiker" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "Token is ongeldig" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token has no id" +msgstr "Token is ongeldig" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Token is ongeldig" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Dit veld is verplicht." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Ongeldige waarde." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' waarde moet True of False zijn." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Geef een geldige 'slug' in bestaande uit letters, nummers, underscores of " +"koppeltekens." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"Selecteer een geldige keuze. %(value)s is niet één van de aanwezige " +"keuzemogelijkheden." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Jou e-mail domein is niet toegestaan" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Voeg een geldig e-mail adres toe." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" +"Datum heeft het verkeerde formaat. Gebruik één van de volgende formaten: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" +"Datum en tijd heeft het verkeerde formaat. Gebruik één van de volgende " +"formaten: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" +"Tijd heeft een verkeerd formaat. Gebruik één van de volgende formaten: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Geef een geheel getal in." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Zorg ervoor dat deze waarde minder of gelijk is aan %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Zorg ervoor dat deze waarde groter of gelijk is aan %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" waarde dient een float te zijn." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Geef een getal in." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Zorg ervoor dat er niet meer dan %s nummers in totaal zijn." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Zorg ervoor dat er niet meer dan %s plaatsen na de comma zijn." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Zorg ervoor dat er niet meer dan %s nummers voor de comma staan." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" +"Er was geen bestand aangegeven. Bekijken het type encoding in het formulier." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Er was geen bestand aangegeven." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Het gegeven bestand is leeg." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Zorg ervoor dat deze bestandsnaam maximaal %(max)d tekens lang is (de naam " +"heeft %(length)d tekens)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" +"Gelieve ofwel een bestand mee te geven ofwel de checkbox aan te tikken, niet " +"beide." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Upload een geldige afbeelding. Het bestand dat je hebt geuploadet was ofwel " +"een afbeelding ofwel een corrupte afbeelding." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Geblokkeerd element" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "Pagina is niet 'last', noch kan het omgezet worden naar een int." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Ongeldige pagina (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Ongeldige definitie van permissie." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Ongeldige pk '%s' - object bestaat niet." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Incorrect type. Pk waarde werd verwacht, maar %s gekregen." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Object met %s=%s bestaat niet." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Ongeldige hyperlink - Geen URL match" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Ongeldige hyperlink - Incorrecte URL match" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Ongeldige hyperlink door configuratiefout" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Ongeldige hyperlink - object bestaat niet." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Incorrect type. Url string werd verwacht, maar %s gekregen." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Ongeldige data" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Geen input gegeven" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Kan geen nieuw item aanmaken, enkel bestaande items mogen bijgewerkt worden." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Verwachtte een lijst van items." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Niet gevonden" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Toestemming geweigerd" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Server applicatie fout" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Verbindingsfout." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Slecht gevormde request." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Incorrecte authenticatie gegevens." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Authenticatie gegevens werden niet gegeven." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Je hebt geen toestemming om deze actie te ondernemen." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Methode '%s' is niet toegestaan." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Kon niet voldoen aan de Accept header van de request" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Niet ondersteund media type '%s' in de request." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Request werd gethrottled." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Verwachtte beschikbaarheid in %d second%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Onverwachte fout" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Niet gevonden." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Methode niet ondersteund voor dit endpoint." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Verkeerde argumenten." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Data validatie fout" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Integriteitsfout voor verkeerde of ongeldige argumenten" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Preconditie fout" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Er is geen ruimte over voor meer projecten." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Fout in filter params types." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project' moet een integer waarde zijn." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Je bent getaiganiseerd." + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Volg ons op Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Haal de code op GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Updates" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Updates" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

commentaar:

\n" +"

%(comment)s

" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Commentaar: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Host toegang fout" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "IP adres fout" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "User story aangemaakt" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "User story gewijzigd" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "User story verwijderd" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "US #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Taak aangemaakt" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Taak gewijzigd" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Taak verwijderd" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Taak #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Probleem aangemaakt" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Probleem gewijzigd" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Probleem verwijderd" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Probleem: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Wiki Pagina aangemaakt" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Wiki Pagina gewijzigd" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Wiki Pagina verwijderd" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Wiki pagina: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Sprint aangemaakt" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Sprint gewijzigd" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Sprint verwijderd" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Sprint: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "We hadden minstens één rol nodig" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Dump file nodig" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Ongeldig dump formaat" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "fout bij het importeren van project data" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "fout bij importeren rollen" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "fout bij importeren lidmaatschappen" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "fout bij importeren van project attributenlijst" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "fout bij het importeren van aangepaste attributen" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "fout bij het importeren van sprints" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "fout bij het importeren van problemen" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "fout bij het importeren van user stories" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "fout bij het importeren van epics" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "fout bij importeren taken" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "fout bij importeren wiki pagina's" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "fout bij importeren wiki links" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "fout bij importeren tags" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "fout bij importeren tijdlijnen" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "onverwachte fout tijdens het importeren van het project" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Fout bij genereren project dump" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Fout bij laden project dump" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Fout tijdens het laden van de project dump file" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr " -- geen gedetailleerde informatie --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] De project dump werd gegenereerd" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Je project dump is geïmporteerd" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" niet gevonden in dit project" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Ongeldige inhoud. Volgend formaat geldt {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Authenticatie verreist" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "naam" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "Icoon url" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "omschrijving" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Volgende url" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "applicatie" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Ongeldig token" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "volledige naam" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "e-mail adres" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "commentaar" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "aanmaakdatum" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Feedback

\n" +"

Taiga heeft feedback ontvangen van %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Commentaar

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Extra info" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- Van: %(full_name)s <%(email)s>\n" +"---------\n" +"- Commentaar:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Extra info:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Feedback van %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Het project bestaat niet" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Slechte signature" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Ongeldige issue commentaar informatie" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Ongeldige issue informatie" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Het element waarnaar verwezen wordt bestaat niet" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "De status bestaat niet" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Bekijk project" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Bekijk milestones" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Bekijk user stories" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Bekijk taken" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Bekijk issues" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Bekijk wiki pagina's" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Bekijk wiki links" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Voeg milestone toe" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Wijzig milestone" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Verwijder milestone" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Bekijk user story" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Voeg user story toe" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Wijzig user story" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Verwijder user story" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Voeg taak toe" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Wijzig taak" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Verwijder taak" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Voeg issue toe" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Wijzig issue" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Verwijder issue" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Voeg wiki pagina toe" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Wijzig wiki pagina" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Verwijder wiki pagina" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Voeg wiki link toe" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Wijzig wiki link" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Verwijder wiki link" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Wijzig project" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Verwijder project" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Voeg lid toe" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Verwijder lid" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Admin project waarden" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Admin rollen" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "eigenaar" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Onvolledige argumenten" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Ongeldig afbeelding formaat" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "Dit veld is verplicht." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "project" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "inhoud type" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "object id" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "gemodifieerde datum" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "bijgevoegd bestand" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "is verouderd" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "volgorde" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "type" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "waarden" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "user story" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "taak" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "issue" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Er bestaat er al één met dezelfde naam." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "status" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "onderwerp" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "kleur" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "toegewezen aan" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "is requirement van de klant" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "is requirement van het team" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "externe referentie" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Commentaar is al verwijderd" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Commentaar niet verwijderd" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Verander" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Creëer" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Verwijder" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s rol punten" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "van" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "naar" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Nieuwe bijlage toegevoegd" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Bijlage bijgewerkt" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "verouderd" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "niet verouderd" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Bijlage verwijderd" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "toegevoegd" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "verwijderd" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Niet toegewezen" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-verwijderd-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "naar:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "van:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Toegevoegd" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Veranderd" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Verwijderd" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "toegevoegd:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "verwijderd:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Van:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Naar:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "inhoud" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "geblokkeerde notitie" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Je hebt geen toestemming om deze sprint op deze issue te zetten." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Je hebt geen toestemming om deze status toe te kennen aan dze issue." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" +"Je hebt geen toestemming om dit ernstniveau toe te kennen aan deze issue." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" +"Je hebt geen toestemming om deze prioriteit toe te kennen aan deze issue." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Je hebt geen toestemming om dit type toe te kennen aan deze issue." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "erstniveau" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "prioriteit" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "milestone" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "datum van afwerking" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Vind ik leuk" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Personen die dit leuk vinden" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "slug" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "geschatte start datum" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "geschatte datum van afwerking" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "is gesloten" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "beschikbaarheid" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "The geschatte start moet vroeger zijn dan het geschatte einde." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "is geblokkeerd" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' parameter is verplicht" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' parameter is verplicht" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "e-mail" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "aangemaakt op" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "token" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "uitnodiging extra text" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "gebruiker volgorde" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "The gebruikers is al lid van het project" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "standaard US status" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "standaard punten" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "default taak status" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "standaard prioriteit" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "standaard ernstniveau" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "standaard issue status" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "standaard issue type" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "leden" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "totaal van de milestones" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "totaal story points" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "actief backlog paneel" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "actief kanban paneel" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "actief wiki paneel" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "actief issues paneel" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "videoconference systeem" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "aanmaak template" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "is privé" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "anonieme toestemmingen" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "gebruikers toestemmingen" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "gewijzigde datum en tijd" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "module config" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "is gearchiveerd" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "work in progress limiet" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "waarde" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "standaard rol eigenaar" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "standaard instellingen" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "us statussen" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "punten" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "taak statussen" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "issue statussen" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "issue types" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "prioriteiten" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "ernstniveaus" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "rollen" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "aanmaak datum en tijd" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "geschiedenis items" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "verwittig gebruikers" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Verwittiging bestaat voor gespecifieerde gebruiker en project" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Issue bijgewerkt#%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Issue aangemaakt #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Issue verwijderd #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Sprint bijgewerkt\"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Sprint aangemaakt \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Sprint verwijderd \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Taak bijgewerkt#%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Taak aangemaakt #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Taak verwijderd #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] US bijgewerkt#%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] US aangemaakt #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] US verwijderd #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Wiki Pagina bijgewerkt \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Wiki Pagina aangemaakt \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Wiki Pagina verwijderd \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Volgers bevat ongeldige gebruikers" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "De versie moet een integer zijn" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "De versie stemt niet overeen met de huidige waarde" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "versie" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The project doesn't exist" +msgid "User does not exist." +msgstr "Het project bestaat niet" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Deze gebruiker is al lid van het project." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "nieuw e-mail adres" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Toekomstige sprint" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Project einde" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Token is ongeldig" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "tags" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "tag kleuren" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "us volgorde" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "takenbord volgorde" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "is iocaine" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "iemand" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Accepteer je uitnodiging tot Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Accepteer je uitnodiging" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"En nu enkele woorden van de blije broeder of zuster die zo vriendelijk was " +"om je uit te nodigen:\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Accepteer je uitnodiging tot Taiga via onderstaande link:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Uitnodiging om toe te treden tot project '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Toegevoegd aan het project '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"De agile product backlog in Scrum is een geprioritiseerde lijst van " +"features, het bevat korte omschrijvingen van alle functionaliteit die men " +"verwacht van het product. Bij het toepassen van Scrum is het niet nodig om " +"een project te starten waar op voorhand grote moeite gedaan werd om alle " +"requirements te documenteren. De Scrum product backlog kan op die manier " +"groeien en veranderen naarmate men meer leert over het product en de " +"gebruikers" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban is een methode om kenniswerk te beheren met een nadruk om just-in-" +"time aflevering terwijl we toch de teamleden niet willen overladen. Via deze " +"benadering wordt het werk door teamleden van een queue afgehaald, van " +"definitie tot taak tot het afleveren naar de klant." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Nieuw" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Klaar" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "Lopende" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Klaar om te testen" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Afgewerkt" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Gearchiveerd" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Gesloten" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Info nodig" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Verzet naar later" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Geweigerd" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Bug" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Vraag" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Verbetering" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Laag" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normaal" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Hoog" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Wensenlijst" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Mineur" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Belangrijk" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Kritiek" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Design" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Front" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Back" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Product Owner" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Stakeholder" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "rol" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "backlog volgorde" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "sprint volgorde" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "afwerkdatum" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "gegenereerd van issue" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Er is geen user story met dat id" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Er is geen project met dat is" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Ongeldige rol voor project" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Standaard opties" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Status van User story" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Punten" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Statussen van taken" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Statussen van Issues" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Types van issue" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Prioriteiten" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Ernstniveaus" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Rollen" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Stemmen" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Stem" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'inhoud' parameter is verplicht" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id' parameter is verplicht" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "gebruiker met laatste wijziging" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "leden" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Project einde" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Project einde" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Gedupliceerde e-mail" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Ongeldige gebruikersnaam of e-mail" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Huidig wachtwoord parameter vereist" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Nieuw wachtwoord parameter vereist" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Ongeldig huidig wachtwoord" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "Ongeldig, weet je zeker dat het token correct en ongebruikt is?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Ongeldig, weet je zeker dat het token correct is?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "Mail met succes verzonden!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "superuser status" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Beduidt dat deze gebruik alle toestemmingen heeft zonder deze expliciet toe " +"te wijzen." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "gebruikersnaam" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "Vereist. 30 of minder karakters. Letters, nummers en /./-/_ karakters" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Geef een geldige gebruikersnaam in" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "actief" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Beduidt of deze gebruiker als actief moet behandeld worden. Deselecteer dit " +"i.p.v. accounts te verwijderen." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografie" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "foto" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "toetrededatum" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "toetrededatum" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "standaard taal" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "standaard tijdzone" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "kleur tags" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "e-mail token" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "nieuw e-mail adres" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "toestemmingen" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Gebruikersnaam of wachtwoord stemt niet overeen met gebruiker." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Verander e-mail" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Wachtwoord recuperatie" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Je mag je acccount verwijderen van deze service: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Je bent getaiganiseerd!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "ongeldig" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Ongeldige gebruikersnaam. Probeer met een andere." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"Gedupliceerde key value overtreed unieke constraint. Key '{}' bestaat al." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "key" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "geheime sleutel" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "status code" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "request data" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "request headers" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "response data" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "response headers" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "duur" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Personal info" +#~ msgstr "Persoonlijke info" + +#~ msgid "Permissions" +#~ msgstr "Toestemmingen" + +#~ msgid "Important dates" +#~ msgstr "Belangrijke data" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/permissions.py b/taiga/locale/permissions.py new file mode 100644 index 000000000..b53a5a8eb --- /dev/null +++ b/taiga/locale/permissions.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission, AllowAny + + +class LocalesPermission(TaigaResourcePermission): + global_perms = AllowAny() diff --git a/taiga/locale/pl/LC_MESSAGES/django.po b/taiga/locale/pl/LC_MESSAGES/django.po new file mode 100644 index 000000000..7037e0fc0 --- /dev/null +++ b/taiga/locale/pl/LC_MESSAGES/django.po @@ -0,0 +1,4993 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# David Barragán , 2015 +# Karol Sokolowski , 2017 +# Konrad Krawczuk , 2020 +# Łukasz Sokolnicki , 2018 +# Stanisław Plebanek , 2019 +# Wiktor Żurawik , 2015 +# Wojtek Jurkowlaniec , 2015 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2024-07-23 09:42+0000\n" +"Last-Translator: Grzegorz Kawka-Osik \n" +"Language-Team: Polish \n" +"Language: pl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (" +"n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && " +"n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" +"X-Generator: Weblate 5.7-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "Nieprawidłowy typ logowania" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "Publiczna rejestracja jest wyłączona." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" +"Musisz zaakceptować nasze ogólne warunki użytkowania oraz politykę " +"prywatności" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "Niepoprawny typ rejestracji" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "Nagłówek Authorization musi posiadać dwie wartości rozdzielone spacją" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "Podany token nie jest poprawny dla żadnego typu tokenu" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "Token nie posiada informacji identyfikującej konto" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "Konto nie znalezione" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "Konto nieaktywne" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "Nierozpoznany typ algorytmu '{}'" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "Cryptography musi być zainstalowane by używać {}." + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "Wskazano niepoprawny algorytm" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "Token jest nieprawidłowy lub wygasł" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "Nieprawidłowa nazwa użytkownika" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Wymagane. Maksymalnie 255 znaków. Litery, cyfry oraz /./-/_" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Nieprawidłowe dane imienia i nazwiska" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "Nie znaleziono aktywnego konta z takimi danymi logowania" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Nazwa użytkownika jest już używana." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Ten adres email jest już używany." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Użytkownik już zarejestrowany." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Błąd w trakcie tworzenia nowego użytkownika." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "jti" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "użytkownik" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "utworzono" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "wygasa" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "Lista zablokowanych tokenów" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no id" +msgstr "Token wygasł" + +#: taiga/auth/tokens.py:138 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no type" +msgstr "Token wygasł" + +#: taiga/auth/tokens.py:141 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has wrong type" +msgstr "Token wygasł" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +#, fuzzy +#| msgid "Token has expired" +msgid "Token '{}' claim has expired" +msgstr "Token wygasł" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Nieprawidłowy token." + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "To pole jest wymagane." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Nieprawidłowa wartość." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' wartość musi przyjąć True albo False," + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Podaj prawidłowy 'slug' zawierający litery, cyfry, podkreślenia lub myślniki." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"Dokonał właściwego wyboru. Wartość %(value)s nie jest jedną z dostępnych " +"opcji." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Twoja domena email jest niedozwolona." + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Podaj właściwy adres email." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "Zły format. Użyj jednego z tych formatów: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "Zły format. Użyj jednego z tych formatów: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "Zły format. Użyj jednego z tych formatów: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Wpisz cały numer" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Upewnij się, że wartość jest mniejsza lub równa od %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Upewnij się, że wartość jest większa lub równa od %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" wartość musi być zmiennoprzecinkowa." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Wpisz numer." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Upewnij się że nie podałeś więcej niż %s znaków." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Upewnij się, że nie ma więcej niż %s miejsc po przecinku." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Upewnij się, że nie ma więcej niż %s cyfr przed przecinkiem." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Plik nie został wysłany. Sprawdź kodowanie znaków w formularzu." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Plik nie został wysłany." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Wysłany plik jest pusty." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Upewnij się, że nazwa pliku ma maksymalnie %(max)d znaków.(Ilość znaków to: " +"%(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "Proszę wybrać jedną z opcji, nie obie." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Prześlij właściwy obraz. Plik który próbujesz przesłać nie jest obrazem lub " +"jest uszkodzony." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Element zablokowany" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "Strona nie jest ostatnią i nie może zostać zmieniona na int." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Niewłaściwa strona (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Nieprawidłowa definicja uprawnień." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Nieprawidłowa wartość klucza '%s' -Obiekt nie istniej." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Niepoprawny typ. Oczekiwana wartość, otrzymana %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Obiekt z %s=%s nie istnieje." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Nieprawidłowy odnośnik - brak pasującego URL" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Nieprawidłowy odnośnik - źle dopasowany URL" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Nieprawidłowy odnośnik z powodu błędu konfiguracji" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Nieprawidłowy odnośnik - obiekt nie istnieje." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Niepoprawny typ. Oczekiwany url, otrzymany %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Nieprawidłowa dana" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Nic nie wpisano" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Nie można utworzyć nowego obiektu, tylko istniejące obiekty mogą być " +"aktualizowane." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Oczekiwana lista elementów." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Nie znaleziono" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Dostęp zabroniony" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Błąd aplikacji serwera" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Błąd połączenia." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Błędne żądanie." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Nieprawidłowe dane uwierzytelniające." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Nie podano danych uwierzytelniających." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Nie masz uprawnień do wykonania tej czynności." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Metoda %s nie dozwolona." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Nie udało się spełnić żądania Accept Header" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Niewspierany typ pliku '%s' w żądaniu." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Żądanie zostało zduszone." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Oczekiwana dostępność w ciągu %d sekund%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Nieoczekiwany błąd" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Nie odnaleziono." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Metoda nie wspierana dla tej końcówki." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Złe argumenty." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Błąd walidacji dancyh" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Błąd integralności dla błędnych lub nieprawidłowych argumentów" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Błąd warunków wstępnych" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Nie ma miejsca na więcej projektów." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Błąd w parametrach typów filtrów." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project' musi być wartością typu int." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Zostałeś zaTaigowany :)" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Obserwuj nas na Twitterze" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Pobierz kog z GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Aktualizacje" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Aktualizacje" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

komentarz:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Komentarz: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "Historyjka użytkownika utworzona" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "Historyjka użytkownika zmodyfikowana" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "Historyjka użytkownika usunięta" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Zadanie utworzone" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Zadanie zmodyfikowane" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Zadanie usunięte" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Strona Wiki utworzona" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Strona Wiki zmodyfikowana" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Strona Wiki usunięta" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Sprint utworzony" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Sprint zmodyfikowany" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Sprint usunięty" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Sprint: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Potrzeba conajmiej jednej roli" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Wymagany plik zrzutu" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Nieprawidłowy format zrzutu" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "błąd w trakcie importu danych projektu" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "błąd w trakcie importu ról" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "błąd w trakcie importu członkostw" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "błąd w trakcie importu atrybutów projektu" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "błąd w trakcie importu niestandardowych atrybutów" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "błąd w trakcie importu sprintów" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "błąd w trakcie importu zgłoszeń" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "błąd w trakcie importu historyjek użytkownika" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "błąd w importowaniu epiców" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "błąd w trakcie importu zadań" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "błąd w trakcie importu stron Wiki" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "błąd w trakcie importu linków Wiki" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "błąd w trakcie importu tagów" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "błąd w trakcie importu osi czasu" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "nieoczekiwany błąd w importowaniu projektu" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Nie możesz posiadać więcej prywatnych projektów" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Nie możesz posiadać więcej publicznych projektów" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Błąd w trakcie generowania zrzutu projektu" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"Błąd ładowania zrzutu przez {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"Powód:\n" +"-------\n" +"{reason}\n" +"\n" +"Detale:\n" +"--------\n" +"{details}\n" +"\n" +"Ślad błędu:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Błąd w trakcie wczytywania zrzutu projektu" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Błąd ładowania twojego zrzutu projektu" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "-- nie ma szegółowego info --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] Twój plik zrzutu został wygenerowany" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Twój zrzut projektu został prawidłowo zaimportowany" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" nie odnaleziono w projekcie" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Niewłaściwa zawartość. Musi to być {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Autentyfikacja wymagana" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "nazwa" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "URL ikony" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "opis" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Następny url" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "aplikacja" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Nieprawidłowy token" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "Imię i Nazwisko" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "adres e-mail" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "komentarz" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "data utworzenia" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Feedback

\n" +"

Taiga otrzymała informacje od %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Komentarz

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Dodatkowe info" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- Od: %(full_name)s <%(email)s>\n" +"---------\n" +"- Komentarz:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Dodatkowe info:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Informacje od %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Projekt nie istnieje" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Błędna sygnatura" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"Komentarz od {platform}:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Nieprawidłowa informacja o komentarzu do zgłoszenia" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Problem stworzony z {platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Nieprawidłowa informacja o zgłoszeniu" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "nieznany użytkownik" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"Zmienił status z {platform} commit.\n" +"\n" +"- Status: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" +"Ten problem został wspomniany w {platform} commit \"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Element referencyjny nie istnieje" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Status nie istnieje" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Parametr projektu jest potrzebny" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Nieprawidłowe wywołanie API Asana" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "Nie udało się wywołać API Asana" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Potrzebny parametr kodu" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Błąd w imporcie projektu Asana" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Błąd w danych autentyfikacji" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "Serwis trzecich osób zawiódł" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "Błąd importowania projektu GitHub" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "Parametr URL jest potrzebny" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "Nieprawidłowy project_type {}" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "Nieprawidłowy albo przeterminowany token autentifikacji" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Błąd importu projektu Jira" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "Błąd importu projektu PivotalTracker" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Twój projekt z Asany został zaimportowany" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] Twój projekt z GitHub'a został zaimportowany" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Twój projekt z Jiry został zaimportowany" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Twój projekt z Trello został zaimportowany" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Zobacz projekt" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Zobacz kamienie milowe" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Zobacz historyjki użytkownika" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Zobacz zadania" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Zobacz zgłoszenia" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Zobacz strony Wiki" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Zobacz linki Wiki" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Dodaj kamień milowy" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Modyfikuj Kamień milowy" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Usuń kamień milowy" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Zobacz historyjkę użytkownika" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Dodaj historyjkę użytkownika" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Modyfikuj historyjkę użytkownika" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "Skomentuj historyjkę użytkownika" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Usuń historyjkę użytkownika" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Dodaj zadanie" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Modyfikuj zadanie" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Skomentuj zadanie" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Usuń zadanie" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Dodaj zgłoszenie" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Modyfikuj zgłoszenie" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Usuń zgłoszenie" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Dodaj strony Wiki" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Modyfikuj stronę Wiki" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Skomentuj stronę Wiki" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Usuń stronę Wiki" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Dodaj link do Wiki" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Modyfikuj link do Wiki" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Usuń link Wiki" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Modyfikuj projekt" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Usuń projekt" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Dodaj członka zespołu" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Usuń członka zespołu" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Administruj wartościami projektu" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Administruj rolami" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "właściciel" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Pola niekompletne" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Niepoprawny format obrazka" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "To pole jest wymagane." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "projekt" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "typ zawartości" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "id obiektu" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "data modyfikacji" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "załączony plik" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "jest przestarzałe" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "kolejność" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Niestandardowy" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Tekst" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Teks wielowierszowy" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "typ" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "wartości" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "historyjka użytkownika" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "zadanie" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "zgłoszenie" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Już istnieje jeden z taką nazwą." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "status" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "temat" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "kolor" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "przypisane do" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "wymaganie klienta" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "wymaganie zespołu" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "źródło zgłoszenia" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Komentarz został już usunięty" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Komentarz nie został usunięty" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Zmień" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Utwórz" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Usuń" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s punkty roli" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "od" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "do" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Dodano nowy załącznik" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Zaktualizowany załącznik" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "przestarzałe" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "nie przestarzałe" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Usuń załącznik" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "dodane" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "usuniete" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Nieprzypisane" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-usunięte-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "do:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "od:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Dodane" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Zmienione" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Usunięte" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "dodane:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "usunięte:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Od:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Do:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "zawartość" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "zaglokowana notatka" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "sprint" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Nie masz uprawnień do połączenia tego zgłoszenia ze sprintem." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Nie masz uprawnień do ustawienia statusu dla tego zgłoszenia." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "Nie masz uprawnień do ustawienia ważności dla tego zgłoszenia." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "Nie masz uprawnień do ustawienia priorytetu dla tego zgłoszenia." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Nie masz uprawnień do ustawienia typu dla tego zgłoszenia." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "ważność" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "priorytet" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "kamień milowy" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "data zakończenia" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "Nieprawidłowy kamień milowy" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "slug" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "szacowana data rozpoczecia" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "szacowana data zakończenia" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "jest zamknięte" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "dostępność" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "Szacowana data rozpoczęcia musi być wcześniejsza niż data zakończenia." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "jest zablokowane" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' parametr jest obowiązkowy" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' parametr jest obowiązkowy" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "e-mail" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "utwórz na" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "token" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "dodatkowy tekst w zaproszeniu" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "kolejność użytkowników" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "Użytkownik już jest członkiem tego projektu" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "domyślny status dla HU" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "domyślne punkty" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "domyślny status dla zadania" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "domyślny priorytet" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "domyślna ważność" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "domyślny status dla zgłoszenia" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "domyślny typ dla zgłoszenia" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "logo" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "członkowie" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "wszystkich kamieni milowych" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "wszystkich punktów " + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "aktywny kontakt" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "aktywny panel backlog" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "aktywny panel Kanban" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "aktywny panel Wiki" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "aktywny panel zgłoszeń " + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "system wideokonferencji" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "dodatkowe dane dla wideokonferencji" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "szablon " + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "jest prywatna" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "uprawnienia anonimowych" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "uprawnienia użytkownika" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "szuka ludzi" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "data aktualizacji" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "ilość" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "konfiguracja modułów" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "zarchiwizowane" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "limit postępu prac" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "wartość" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "domyśla rola właściciela" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "domyślne opcje" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "statusy HU" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "pinkty" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "statusy zadań" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "statusy zgłoszeń" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "typy zgłoszeń" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "priorytety" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "ważność" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "role" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Zaangażowani" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Wszyscy" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Żaden" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "data utworzenia" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "wpisy historii" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "powiadom użytkowników" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Obserwowane" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Powiadomienie istnieje dla określonego użytkownika i projektu" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Nieprawidłowa wartość dla poziomu notyfikacji" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Zaktualizował zgłoszenie #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Utworzył zgłoszenie #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Usunął zgłoszenie #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Zaktualizował sprint\"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Utworzył sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Skasował sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Zaktualizował zadanie #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Utworzył zadanie #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Usunął zadanie #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Zaktualizował HU #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Utworzył HU #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Usunął HU #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Zaktualizował stronę Wiki\"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Zaktualizował stronę Wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Usunął stronę Wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Obserwatorzy zawierają niepoprawnych użytkowników" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "Wersja musi być integerem ;)" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "Parametr wersji jest nieprawidłowy" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Podana wersja nie zgadza się z aktualną." + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "wersja" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "Token nie pasuje do żadnego aktywnego zaproszenia. " + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The tag doesn't exist." +msgid "User does not exist." +msgstr "Tag nie istnieje" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Ten użytkownik jest już członkiem projektu." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "nowy adres e-mail" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Projekt bez właściciela" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Przyszły sprint" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Zakończenie projektu" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Nieprawidłowy token." + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Token wygasł" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "Nieprawidłowy tag '{value}'. To musi być nazwa tagu." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "tagi" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "kolory tagów" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Taki tag już istnieje." + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "Nieprawidłowy kolor w systemie HEX." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "Tag nie istnieje" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "Nie masz uprawnień do ustawiania sprintu dla tego zadania." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" +"Nie masz uprawnień do ustawiania historyjki użytkownika dla tego zadania" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "Nie masz uprawnień do ustawiania statusu dla tego zadania" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "kolejność HU" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "Kolejność tablicy zadań" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "Iokaina" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "Nieprawidłowy identyfikator kamienia milowego" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "Nieprawidłowy identyfikator statusu." + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "ktoś" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

A poniżej kilka słów od kogoś kto był tak miły
i zechciał " +"zaprosić Cię do projektu :)

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Zaakceptuj zaproszenie do Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Zaakceptuj zaproszenie" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"A poniżej kilka słów od kogoś kto był tak miły
i zechciał zaprosić Cię " +"do projektu :)\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Zaakceptuj zaproszenie do Taiga klikając w ten link: " + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Zaproszenie do dołączenia, do projektu '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Dodany do projektu '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s mówi:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

Od teraz, twoim nowym statusem dla tego projektu jest \"admin\".\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s mówi:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"Od teraz, twoim nowym statusem dla tego projektu jest \"admin\".\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] Propozycja zmiany właściciela projektu zaakceptowana!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s mówi:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s mówi:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Kontynuuj" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Przejdź do ustawień projektu:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +" Cześć %(receiver_name)s,

\n" +"

%(owner_name)s, aktualny właściciel projektu \"%(project_name)s\" " +"chciałby Cię mianować nowym właścicielem projektu.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s mówi:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s mówi:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"Proszę przejść do poniższego linku aby zaakceptować lub odrzucić tę " +"propozycję.

\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "Zaakceptuj bądź odrzuć zmianę właściciela projektu:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Scrum w oparciu o Taiga, które jest kompletnym narzędziem zawierającym " +"product backlog, opisy wszystkich funkcji i wskazówki to przyjemność. " +"Pracując w Scrumie nie musisz rozpoczynać projektu projektu od " +"dokumentowania wszystkiego a więc możesz zacząć pracować znacznie szybciej. " +"Product backlog jest na tyle elastyczny, że umożliwia szybki wzrost i oswaja " +"zmiany wraz z tym jak cały zespół poznaje specyfikę projektu i wymagania " +"klienta." + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban jest metodą kierowania pracami, ze szczególnym naciskiem na dostawy " +"just-in-time, bez przeciążania członków zespołu. W tym podejściu, zadania są " +"wyświetlane dla klienta a członkowie zespołu wyciągają je z kolejki." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Nowe" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Gotowe" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "W toku" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Gotowe do testów" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Gotowe!" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Zarchiwizowane" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Zamknięte" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Potrzebne informacje" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Odroczone" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Odrzucone" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Błąd" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Pytanie" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Ulepszenie" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Niski" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normalny" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Wysoki" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Życzenie" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Pomniejsze" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Istotne" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Krytyczne" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Design" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Front" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Back" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Właściciel produktu" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Interesariusz" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"Nie masz uprawnień do ustawiania sprintu dla tej historyjki użytkownika." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" +"Nie masz uprawnień do ustawiania statusu do tej historyjki użytkownika." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "Nieprawidłowy identyfikator roli '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "rola" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "Kolejność backlogu" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "kolejność sprintu" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "Zamówienie kanban" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "data zakończenia" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "wygenerowane ze zgłoszenia" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Nie ma historyjki użytkownika z takim ID" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +#, fuzzy +#| msgid "Invalid role ids. All roles must belong to the same project." +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"Nieprawidłowe identyfikatory roli. Wszystkie role muszą należeć do tego " +"samego projektu" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +#, fuzzy +#| msgid "Invalid role ids. All roles must belong to the same project." +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"Nieprawidłowe identyfikatory roli. Wszystkie role muszą należeć do tego " +"samego projektu" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Nie ma projektu z takim ID" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "Użytkownik już istnieje w tym projekcie." + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Nieprawidłowa rola w projekcie" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "Użytkownik musi być prawidłowym kontaktem" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "Właściciel projektu musi być administratorem." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" +"Przynajmniej jeden użytkownik musi być aktywnym administratorem dla tego " +"projektu." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" +"Nieprawidłowe identyfikatory roli. Wszystkie role muszą należeć do tego " +"samego projektu" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Domyślne opcje" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Statusy historyjek użytkownika" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Punkty" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Statusy zadań" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Statusy zgłoszeń" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Typu zgłoszeń" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Priorytety" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Ważność" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Role" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Głosy" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Głos" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "Parametr 'zawartość' jest wymagany" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "Parametr 'id_projektu' jest wymagany" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "ostatnio zmodyfikowane przez" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Dla pełengo diffa sprawdź API historii" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Zespół projektowy" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Zespół projektowy" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "członkowie" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Zakończenie projektu" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Zakończenie projektu" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Zduplikowany adres e-mail" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Nieprawidłowa nazwa użytkownika lub adrs e-mail" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Należy podać bieżące hasło" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Należy podać nowe hasło" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Podałeś nieprawidłowe bieżące hasło" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Niepoprawne, jesteś pewien, że token jest poprawny i nie używałeś go " +"wcześniej? " + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Niepoprawne, jesteś pewien, że token jest poprawny?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "E-mail wysłany poprawnie!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "status SUPERUSER" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Oznacza, że ten użytkownik posiada wszystkie uprawnienia bez konieczności " +"ich przydzielania." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "nazwa użytkownika" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "Wymagane. 30 znaków. Liter, cyfr i znaków /./-/_" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Wprowadź poprawną nazwę użytkownika" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "aktywny" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Oznacza, że ten użytkownik ma być traktowany jako aktywny. Możesz to " +"odznaczyć zamiast usuwać konto." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografia" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "zdjęcie" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "data dołączenia" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "data dołączenia" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "domyślny język Taiga" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "domyślny szablon Taiga" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "domyśla strefa czasowa" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "kolory tagów" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "tokem e-mail" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "nowy adres e-mail" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "maksymalna liczba przypisanych prywatnych projektów" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "maksymalna liczba przypisanych publicznych projektów" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of owned private projects" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "maksymalna liczba przypisanych prywatnych projektów" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of owned public projects" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "maksymalna liczba przypisanych publicznych projektów" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "uprawnienia" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Nazwa użytkownika lub hasło są nieprawidłowe" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Zmienił e-mail" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Odzyskał hasło" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" Możesz usunąć swoje konto klikając tutaj\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Możesz usunąć swoje konto z tego serwisu: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Zostałeś zaTaigowany" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "Niepoprawne" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Niepoprawna nazwa użytkownika. Spróbuj podać inną." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "Duplikowanie wartości klucza. Klucz '{}' już istnieje." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "klucz" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "sekretny klucz" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "kod statusu" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "data żądania" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "nagłówki żądań" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "dane odpowiedzi" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "nagłówki odpowiedzi" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "czas trwania" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Personal info" +#~ msgstr "Informacje osobiste" + +#~ msgid "Permissions" +#~ msgstr "Uprawnienia" + +#~ msgid "Restrictions" +#~ msgstr "Ograniczenia" + +#~ msgid "Important dates" +#~ msgstr "Ważne daty" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/pt_BR/LC_MESSAGES/django.po b/taiga/locale/pt_BR/LC_MESSAGES/django.po new file mode 100644 index 000000000..746f824a7 --- /dev/null +++ b/taiga/locale/pt_BR/LC_MESSAGES/django.po @@ -0,0 +1,5095 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Antônio "acdc" Jr. , 2016 +# Breno Uchoa , 2017 +# Claudio Ferreira , 2017 +# Cléber Zavadniak , 2015 +# Thiago Muramatsu , 2015 +# Daniel Dias de Assumpção , 2015 +# Danilo Almeida , 2018 +# David Barragán , 2015 +# Hevertton Barbosa , 2015 +# Kemel Zaidan , 2015 +# Laura Capurro , 2020 +# Lennon Jesus , 2016 +# Lucas Boscaini , 2017 +# Mairieli Wessel , 2016 +# Marcos Insfran , 2019 +# Marlon Carvalho , 2015 +# Michael Douglas Meneses de Souza , 2018 +# Michael Douglas Meneses de Souza , 2018 +# Michel Wilhelm , 2016 +# pedromvm , 2015 +# pedromvm , 2015 +# Pedro Rangel Raft , 2017 +# Pedro Rangel Raft , 2017 +# Renato Prado , 2015 +# Thiago Almeida , 2016 +# Thiago Muramatsu , 2015 +# Thiago Muramatsu , 2015 +# Walker de Alencar , 2015 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2024-07-17 12:09+0000\n" +"Last-Translator: \"A. Bento\" \n" +"Language-Team: Portuguese (Brazil) \n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 5.7-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "tipo de login inválido" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" +"Você deve aceitar os nossos termos do serviço e a política de privacidade" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "Você deve aceitar os termos do serviço e a política de privacidade" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "tipo de registro inválido" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" +"O cabeçalho de autorização deve conter dois valores delimitados por espaços" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "O token fornecido não é válido para nenhum tipo de token" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "O token não continha nenhuma identificação de usuário reconhecível" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "Usuário não encontrado" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "O usuário está inativo" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "Tipo de algoritmo '{}' não reconhecido" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "Você deve ter a criptografia instalada para utilizar {}." + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "Algoritmo especificado inválido" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "Token é inválido ou expirou" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "nome de usuário inválido" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Obrigatório. No máximo 255 caracteres. Letras, números e /./-/_ ." + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Nome completo inválido" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "Nenhuma conta ativa encontrada com as credenciais fornecidas" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Nome de usuário já está em uso." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Este e-mail já está em uso." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Usuário já cadastrado." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Erro ao criar um novo usuário." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "jti" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "usuário" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "criado em" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "expira em" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "Lista de negação de tokens" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "Não é possível criar um token sem tipo ou tempo de vida" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "O token não tem identificador" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "O token não tem tipo" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "O token tem o tipo errado" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "O token não '{}' tem declaração" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "Token '{}' expirou" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "O token está na lista de negação" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Este campo é obrigatório." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Valor inválido." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "O valor de '%s' deve ser ou True ou False." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Entre uma 'slug' válida, consistindo de letras, números, underscores ou " +"hífens." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "Escolha uma alternativa válida. %(value)s não está disponível." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Seu domínio de email não é permitido" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Preencha com um e-mail válido." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "A data está no formato errado. Use um desses no lugar: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "Formato da data e hora errado. Use um destes: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "Hora com formato errado. Use um destes: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Insira um número inteiro." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Garanta que o valor é menor ou igual a %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Garanta que o valor é maior ou igual a %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "O valor de \"%s\" deve ser decimal (float)." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Insira um número." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Garanta que não há mais que %s dígitos no total." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Garanta que não há mais que %s casas decimais." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Garanta que não há mais que %s dígitos antes do ponto decimal." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Nenhum arquivo enviado. Verifique o tipo de codificação no formulário." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Nenhum arquivo enviado." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "O arquivo enviado está vazio." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Garanta que o nome do arquivo tem no máximo %(max)d caracteres (no momento " +"tem %(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "Envie um arquivo ou marque o checkbox \"vazio\", não ambos." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Envie uma imagem válida. O arquivo que você mandou ou não era uma imagem ou " +"está corrompido." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Elemento bloqeado" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "Página não é \"última\", nem pode ser convertída para um inteiro." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Página inválida (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Definição de permissão inválida." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Chave primária '%s' inválida - objeto não existe." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Tipo incorreto. Esperado valor de chave primária, recebido %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Objeto com %s=%s não existe." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Hyperlink inválido - Nenhuma URL corresponde" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Hyperlink inválido - Corresponde a URL incorreta" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Hyperlink inválido devido a erro de configuração" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Hyperlink inválido - objeto não existe." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Tipo incorreto. Esperada string de url, recebido %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Dados inválidos" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Nenhuma entrada providenciada" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Não é possível criar um novo item, somente itens já existentes podem ser " +"atualizados." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Esperada uma lista de itens." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Não encontrado" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Permissão negada" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Erro no servidor da aplicação" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Erro na conexão." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Requisição mal-formada" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Credenciais de autenticação incorretas." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Credenciais de autenticação não informadas." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Você não possui permissão para executar esta ação." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Método '%s' não é permitido" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Não foi possível satisfazer o cabeçalho Accept da requisição" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Tipo de mídia '%s' não suportado na requisição." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Requisição foi sujeita a limites." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Esperado disponível em %d segundo%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Erro inesperado" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Não encontrado." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Método não suportado por esse endpoint." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Argumentos errados." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Erro de validação dos dados" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Erro de Integridade para argumentos inválidos ou errados" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Erro de pré-condição" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Não há mais espaço para mais projetos." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s não é uma lista" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Erro nos tipos de parâmetros do filtro." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'projeto' deve ser um valor inteiro." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Você foi Taigatizado" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

Bem-vindo ao " +"%(product_name)s, uma ferramenta de código aberto para Gestão Ágil de " +"Projetos

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" Configure notificações por e-" +"mail ou cancele a inscrição\n" +"  • \n" +" Suporte Taiga\n" +"  • \n" +" Contate-nos\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Siga-nos no Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Pegue o código no GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Atualizações" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Atualizações" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

comentário:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Comentário: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Erro de acesso ao host (a hospedagem)" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "Erro de endereço IP" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "História de Usuário criada" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "História de Usuário alterada" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "História de Usuário excluída" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "US #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Tarefa criada" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Tarefa alterada" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Tarefa excluída" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Tarefa #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Issue criada" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Issue alterada" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Issue excluída" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Issue: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Página Wiki criada" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Página Wiki alterada" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Página Wiki excluída" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Página Wiki: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Sprint criada" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Sprint alterada" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Sprint excluída" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Sprint: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Nós precisamos de pelo menos uma função" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Necessário de arquivo de restauração" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Formato de aquivo de restauração inválido" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "erro ao importar informações de projeto" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "erro ao importar funcões" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "erro ao importar filiações" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "erro ao importar lista de atributos do projeto" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "erro ao importar valores de atributos padrão do projeto" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "erro ao importar atributos personalizados" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "erro ao importar sprints" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "erro ao importar problemas" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "erro ao importar histórias de usuário" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "Erro ao importar épicos" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "erro ao importar tarefas" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "erro ao importar páginas wiki" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "erro ao importar wiki links" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "erro ao importar tags" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "erro ao importar linha do tempo" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "erro inesperado ao importar projeto" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Você não pode ter mais projetos privados" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" +"Este projeto atingiu o seu limite atual de membros para projetos privados" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Você não pode ter mais projetos públicos" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" +"Este projeto atingiu o seu limite atual de membros para projetos públicos" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Erro gerando arquivo de restauração do projeto" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"\n" +"Erro ao carregar arquivo de restauração por {user_full_name} <{user_email}>:" +"\"\n" +"\n" +"\n" +"\n" +"\n" +"MOTIVO:\n" +"\n" +"-------\n" +"\n" +"{reason}\n" +"\n" +"\n" +"DETALHES:\n" +"--------\n" +"{details}\n" +"\n" +"MAIS INFORMAÇÕES DO ERRO:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Erro carregando arquivo de restauração do projeto" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Erro ao carregar arquivo de restauração do projeto" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr " -- sem informações detalhadas --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Descarte de projeto gerado

\n" +"

Olá %(user)s,

\n" +"

Seu descarte do projeto %(project)s foi gerado corretamente.

\n" +"

Você pode baixá-lo aqui:

\n" +" Baixe o arquivo descartado\n" +"

Este arquivo será excluído em %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Olá%(user)s,\n" +"\n" +"Seu descarte do projeto %(project)s foi gerado corretamente. Você pode baixá-" +"lo aqui:\n" +"\n" +"%(url)s\n" +"\n" +"Este arquivo será excluído em %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] Seu arquivo de restauração do projeto foi criado" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Olá%(user)s,

\n" +"

Seu Projeto%(project)s Não foi exportado corretamente.

\n" +"

Os administradores do sistema Taiga foram informados.
Por favor, " +"tente novamente ou entre em contato com a equipe de suporte no\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Olá %(user)s,\n" +"\n" +"%(error_message)s\n" +"Seu projeto %(project)s não foi exportado corretamente.\n" +"\n" +"Os administradores do sistema Taiga foram informados.\n" +"\n" +"Por favor, tente novamente ou entre em contato com a equipe de suporte no " +"%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Olá %(user)s,

\n" +"

Seu projeto não foi importado corretamente.

\n" +"

Os%(product_name)s os administradores do sistema foram informados.
Por favor, tente novamente ou entre em contato com a equipe de suporte no\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Detalhes do erro

\n" +"
%(details)s
\n" +" " + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Olá %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Seu projeto não foi importado corretamente.\n" +"\n" +"Os %(product_name)s administradores do sistema foram informados.\n" +"\n" +"Por favor, tente novamente ou entre em contato com a equipe de suporte no " +"%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Projeto descartado importado

\n" +"

Olá %(user)s,

\n" +"

Seu projeto descartado foi importado corretamente.

\n" +" Vá para %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Olá %(user)s,\n" +"\n" +"Seu projeto descartado foi importado corretamente.\n" +"\n" +"Você pode ver seu projeto %(project)s aqui:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] A restauração do seu projeto foi importada" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" não encontrado nesse projeto" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "conteúdo inválido. Deve ser {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "Contém campos personalizados inválidos." + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "Nome duplicado" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" +"Um \"Epic\" tem uma história relacionada de um projeto externo %(project)s e " +"não pode ser importado" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Autenticação necessária" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "Nome" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "Ícone da url" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "descrição" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Próxima url" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "aplicação" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Token inválido" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "nome completo" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "endereço de e-mail" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "comentário" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "data de criação" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Resposta

\n" +"

Taiga recebeu resposta de %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Comentário

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Informação extra" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- De: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comentário:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Informação extra:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Resposta de %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "O \"payload\" não é uma json válida" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "O projeto não existe" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Assinatura Ruim" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" +"[{user_name}]({user_url} \"Ver {user_name}'s {platform} perfil\") vá " +"[{platform}#{number}]({comment_url} \"para o comentário\"):\n" +"\n" +"\"{comment_message}\"" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"Comentário de {platform}:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Informação de comentário de problema inválida" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Problema criado por [{user_name}]({user_url} \"Veja o perfil " +"{user_name}'s{platform}} \") da [{platform}#{number}]({url} \"Ir para o " +"problema\")." + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Problema criado pela {platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "Problema modificado na{platform}." + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "Problema encerrado na {platform}." + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "Problema reaberto na {platform}." + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Informação de problema inválida" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "usuário desconhecido" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "Este problema foi mencionado no {platform} commit \"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "O elemento referenciado não existe" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "O estatus não existe" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Parâmetro projeto necessário" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "pedido inválido a Asana API" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "Falha ao fazer o pedido a Asana API" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Parâmetro código necessário" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Erro ao importar projeto Asana" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Dados de autenticação invalidos" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "Serviço de terceiro falhando" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "Erro ao importar projeto GitHub" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "O parâmetro url é necessário" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "project_type inválido {}" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "Configuração do servidor Jira inválida." + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "Token de autenticação invalido ou expirado" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Erro ao importar projeto Jira" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "Erro ao importar projeto PivotalTracker" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Seu projeto Asana foi importado" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] Seu projeto GitHub foi importado" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Seu projeto Jira foi importado" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Seu projeto Trello foi importado" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "Requisição inválida: %(text)s em %(url)s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "Não autorizado: %(text)s em %(url)s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "Erro ao importar projeto do Trello" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Ver projeto" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Ver marco de progresso" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Ver épico" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Ver histórias de usuário" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Ver tarefa" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Ver problemas" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Ver página wiki" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Ver links wiki" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Adicionar marco de progresso" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Modificar marco de progresso" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Excluir marco de progresso" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Adicionar épico" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Modificar épico" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "Comentar épico" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Excluir épico" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Ver história de usuário" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Adicionar história de usuário" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Modificar história de usuário" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "Comentar história de usuário" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Excluir história de usuário" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Adicionar tarefa" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Modificar tarefa" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Comentar tarefa" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Excluir tarefa" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Adicionar problema" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Modificar problema" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "Comentar Problema" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Excluir problema" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Adicionar página wiki" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "modificar página wiki" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Comentar página da wiki" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Exluir página wiki" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Adicionar link wiki" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Modificar wiki link" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Exluir link wiki" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Modificar projeto" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Exluir projeto" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Adicionar membro" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Remover membro" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Valores projeto admin" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Funções Admin" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "Módulos" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Valores padrão" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "Atividades" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "Fãs" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "dono" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Tornar público" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Tornar privado" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Argumentos incompletos" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Formato de imagem inválida" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "Id de usuário inválido" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "O usuário não existe" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "E-mail obrigatório" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"O projeto deve ter um dono e pelo menos um dos usuários precisa ser um " +"administrador ativo" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "Atualizações parciais não são suportadas" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "projeto" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "tipo de conteúdo" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "identidade de objeto" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "data modificação" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "arquivo anexado" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "está obsoleto" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "do comentário" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "ordem" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Personalizado" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "Este projeto está bloqueado por problemas de pagamento" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "Este projeto está bloqueado por um administrador" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "Este projeto está bloqueado porque o proprietário deixou o projeto" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "Este projeto está bloqueado enquanto é excluído" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Texto" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Multi-linha" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Data" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "Tipo" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "valores" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "história de usuário" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "tarefa" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "problema" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Já existe um com o mesmo nome." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "status" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "assunto" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "cor" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "assinado a" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "É requerimento do cliente" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "É requerimento do time" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "histórias de usuário" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "referência externa" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "comentário obrigatório" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "comentários excluídos não podem ser editados" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Comentário já excluído" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Comentário não excluído" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Alterar" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Criar" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Excluir" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s pontos de função" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "de" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "para" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Adicionar novos anexos" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Atualizar anexo" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "obsoleto" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "não-obsoleto" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Anexo excluído" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "adicionado" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "removido" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Não-atribuído" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-excluído-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "para:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "de:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Adicionado" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Alterado" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Excluído" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "acrescentado:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "removido:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "De:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Para:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "conteúdo" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "nota bloqueada" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "sprint" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Você não tem permissão para colocar essa sprint para esse problema." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Você não tem permissão para colocar esse status para esse problema." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "Você não tem permissão para colocar essa gravidade para esse problema." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" +"Você não tem permissão para colocar essa prioridade para esse problema." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Você não tem permissão para colocar esse tipo para esse problema." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "severidade" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "prioridade" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "marco de progresso" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "data de término" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Curtir" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Curtidas" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "slug" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "data de início estimada" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "data de encerramento estimada" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "está fechado" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "disponibilidade" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "A estimativa de inicio deve ser anterior a estimativa de encerramento" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "está bloqueado" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' parametro é mandatório" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' parametro é mandatório" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "O usuário deve ser um membro do projeto" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "email" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "criado em" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "token" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "texto extra de convite" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "ordem de usuário" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "O usuário já é membro do projeto" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "status de US padrão" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "pontos padrão" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "status padrão de tarefa" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "prioridade padrão" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "severidade padrão" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "status padrão de problema" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "tipo padrão de problema" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "logotipo" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "membros" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "total de marcos de progresso" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "pontos totais de US" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "painel de backlog ativo" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "painel de kanban ativo" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "painel de wiki ativo" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "painel de problemas ativo" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "sistema de vídeo conferência" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "informação extra de vídeo conferência" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "template de criação" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "é privado" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "permissão anônima" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "permissão de usuário" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "é destaque" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "está procurando colaboradores" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "código bloqueado" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "data de atualização" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "contagem" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "atividades da última semana" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "atividades do último mês" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "atividades do último ano" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "configurações de módulos" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "está arquivado" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "trabalho no limite de progresso" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "valor" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "por padrão" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "função padrão para dono " + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "opções padrão" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "status de US" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "pontos" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "status de tarefa" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "status de problemas" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "tipos de problema" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "prioridades" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "severidades" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "funções" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "atributos personalizados de problema" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Envolvido" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Tudo" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Nada" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "Assinado" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "Mencionado" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "Adicionado como observador" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "Adicionado como membro" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "Comentário" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "Mencionado em comentário" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "data de criação" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "histórico de entradas" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "notificar usuário" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Observado" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Existe notificação para usuário e projeto especifcado" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Valor inválido para nível de notificação" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] excluiu o épico #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Atualização do problema #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Criação do problema #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Excluido o problema #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Atualização da sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Criou o sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Excpluiu o Sprint \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Atualização da tarefa #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Criou a tarefa #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Excluiu a tarefa #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Atualização da História de Usuário #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Criou a US #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Excluiu a HU #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Atualização da página wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Criou a página wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Excluiu a página Wiki \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Observadores contém usuários inválidos" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "A versão precisa ser um inteiro" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "O parâmetro da versão não é válido" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "A versão não corresponde com a atual" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "versão" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"Você não pode deixar o projeto se você é o dono é não há outros " +"administradores" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "O usuário não existe." + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "O usuário já é membro do projeto." + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "Endereço de e-mail malformado." + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Projeto sem dono" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "Você atingiu o seu limite atual de membros para projetos privados" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "Você atingiu o seu limite atual de membros para projetos públicos" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Sprint futuro" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Fim do projeto" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Token é inválido" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Token expirou" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "Linha do tempo" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "Você não tem acesso a esta seção" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "tags" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "cores de tags" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "Você não tem permissão para colocar esse sprint para essa tarefa." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" +"Você não tem permissão para colocar essa história de usuário para essa " +"tarefa." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "Você não tem permissão para colocar esse status para essa tarefa." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "ordenar por US" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "ordenar por quadro de tarefa" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "é Iocaine" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "alguém" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

E agora algumas palavras do bom companheiros ou companheiras
" +"que vieram tão gentilmente convidá-lo

\n" +"

%(extra)s

" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Aceita seu convite para o Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Aceite seu convite" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"E agora algumas palavras do bom companheiro ou companheira que pensou tão " +"gentilmente como convidá-lo:\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Aceite seu convite para o Taiga seguindo esse link:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] convite para se juntar ao projeto '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Adicionado ao projeto '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s diz:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s diz:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] Oferta de transferência de propriedade de projeto aceita!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s diz:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s diz:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Continuar" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s diz:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"O backlog no scrum é uma lista de funcionalidades priorizadas, contendo " +"pequenas descrições de todas as funcionalidades desejadas no produto. Quando " +"se aplicada ao scrum, não é necessário começar com um longo esforço inicial " +"para documentar todos os requisitos. O backlog permite crescer e modificar-" +"se no processo que é compreendido sobre o produto e seus clientes." + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban é um método de gerenciar o trabalho conhecido com ênfase em entregas " +"just-in-time, não sobrecarregando membros dos times. Nessa abordagem, o " +"processo, da definição da tarefa até a entrega para o cliente, é exibida " +"para os participantes verem os próprios membros do time pegar o trabalho de " +"uma lista." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Novo" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Pronto" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "Em andamento" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Pronto para teste" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Terminado" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Arquivado" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Fechado" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Precisa de informação" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Adiado" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Rejeitado" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Bug" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Pergunta" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Melhoria" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Baixa" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normal" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Alta" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Desejável" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Secundário" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Importante" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Crítica" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Design" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Front" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Back" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Product Owner" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Stakeholder" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"Você não tem permissão para colocar esse sprint para essa história de " +"usuário." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" +"Você não tem permissão para colocar esse status para essa história de " +"usuário." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Gerando a história de usuário #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "função" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "ordem do backlog" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "ordem do sprint" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "data de término" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "Gerado do problema" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Não há história de usuário com esse id" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Não há projeto com esse id" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Função inválida para projeto" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "O dono do projeto deve ser um administrador." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" +"Pelo menos one dos usuários deve ser um administrador ativo neste projeto." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Opções padrão" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Status de história de usuário" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Pontos" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Status de tarefas" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Status de problemas" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Tipos de problemas" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Prioridades" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Severidades" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Funções" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Votos" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Vote" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "parâmetro 'conteúdo' é mandatório" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "parametro 'project_id' é mandatório" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "último modificador" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Verifique o histórico da API para a exata diferença" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Membro do Projeto" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Membros do Projeto" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Proprietário do projeto" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Proprietários do projeto" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "Participações" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Fim do projeto" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Fim do projeto" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "E-mail duplicado" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Usuário ou e-mail inválido" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Parâmetro de senha atual necessário" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Parâmetro de nova senha necessário" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Senha atual inválida" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Inválido, você está certo que o token está correto e não foi usado " +"anteriormente?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Inválido, tem certeza que o token está correto?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "E-mail enviado com sucesso" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "status de superuser" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Designa que esse usuário tem todas as permissões sem explicitamente assiná-" +"las" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "usuário" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "Requerido. 30 caracteres ou menos. Letras, números e caracteres /./-/_" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Digite um usuário válido" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "ativo" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Designa quando esse usuário deve ser tratado como ativo. desmarque isso em " +"vez de deletar contas." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografia" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "foto" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "data ingressado" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "data ingressado" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "lingua padrão" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "tema padrão" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "fuso horário padrão" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "tags coloridas" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "token de e-mail" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "novo endereço de email" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "número máximo de projetos privados próprios" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "número máximo de projetos públicos próprios" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "permissões" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Usuário ou senha não correspondem ao usuário" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Troca de email" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Recuperação de senha" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" Você pode remover sua conta desse serviço clicando aqui\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Você já pode remover sua conta desse serviço: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Você foi Taigatizado!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "[Taiga] Verificar email" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "inválido" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Usuário inválido. Tente com um diferente." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"Valor de chave duplicada viola regra de limitação. Chave '{}' já existe." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "chave" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "chave secreta" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "código de status" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "dados da requisição" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "cabeçalhos da requisição" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "dados de resposta" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "cabeçalhos de resposta" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "duração" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Personal info" +#~ msgstr "Informação pessoal" + +#~ msgid "Permissions" +#~ msgstr "Permissões" + +#~ msgid "Restrictions" +#~ msgstr "Restrições" + +#~ msgid "Important dates" +#~ msgstr "Datas importantes" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/ru/LC_MESSAGES/django.po b/taiga/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 000000000..aeff0210a --- /dev/null +++ b/taiga/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,5787 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Alexander Berezin , 2019 +# Alibek Kaparov , 2018 +# Artem Biryukov , 2017 +# cea814b856b6795fed4e76155a00b372_ed44071 <2499c539bc4bd61b3076c2cb6bfa3653_399195>, 2016 +# David Barragán , 2021 +# Denis Vadimov, 2018 +# Denis Vadimov, 2018 +# Дмитрий Пугац , 2015 +# Dmitriy Vinokurov , 2015 +# Dmitriy Volkov , 2015 +# Dmitry Lobanov , 2015 +# Dmitriy Vinokurov , 2015 +# Egor Poderyagin , 2016 +# Igor Bezukladnikov , 2016 +# Ilya Rogov, 2016 +# Ilya Rogov, 2016 +# Inga Krinkina , 2020-2021 +# cea814b856b6795fed4e76155a00b372_ed44071 <2499c539bc4bd61b3076c2cb6bfa3653_399195>, 2016 +# Miguel Gonzalez , 2018 +# Nikolay Grinin , 2020 +# Sergey Kustov , 2016 +# Sergey Kuznetsov , 2021 +# soci0pat , 2020 +# soci0pat , 2020 +# Yegor Lapshin , 2020 +# Данил Тонких , 2017 +# Дмитрий Пугац , 2015 +# Марат , 2015 +# Никита Евстропов , 2016 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-10-19 09:15+0000\n" +"Last-Translator: Artem \n" +"Language-Team: Russian \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " +"(n%100>=11 && n%100<=14)? 2 : 3);\n" +"X-Generator: Weblate 4.9-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "неправильный тип логина" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "Публичная регистрация отключена." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" +"Вы должны принять наши условия использования и политику конфиденциальности" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "неправильный тип регистрации" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "Не найдено" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "Неверная роль для этого проекта" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "Неверный токен" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "неправильное имя пользователя" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Обязательно. 255 символов или меньше. Буквы, числа и символы /./-/_" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Неверное полное имя" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Это имя уже используется." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Этот адрес почты уже используется." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Пользователь уже зарегистрирован." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Ошибка при создании нового пользователя." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "пользователь" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "создано" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "Неверный токен" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no id" +msgstr "Срок действия токена истёк" + +#: taiga/auth/tokens.py:138 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has no type" +msgstr "Срок действия токена истёк" + +#: taiga/auth/tokens.py:141 +#, fuzzy +#| msgid "Token has expired" +msgid "Token has wrong type" +msgstr "Срок действия токена истёк" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +#, fuzzy +#| msgid "Token has expired" +msgid "Token '{}' claim has expired" +msgstr "Срок действия токена истёк" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Неверный токен" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Это поле обязательно." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Неправильное значение." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "значение '%s' должно быть True - верно - или False - ложно." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Введите корректное 'ссылочное имя' состоящее из букв, чисел, подчёркиваний и " +"дефисов." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"Выберите правильное значение. %(value)s не является одним из доступных " +"значений." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Ваш домен email запрещён" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Введите правильный адрес email." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "Дата имеет неверный формат. Воспользуйтесь одним из этих форматов: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" +"Дата и время имеют неправильный формат. Воспользуйтесь одним из этих " +"форматов: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" +"Время имеет неправильный формат. Воспользуйтесь одним из этих форматов: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Введите целое число." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Убедитесь, что это значение меньше или равно %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Убедитесь, что это значение больше или равно %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" значение должно быть числом с плавающей точкой." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Введите число." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Убедитесь, что здесь всего не больше %s цифр." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Убедитесь, что здесь не больше %s цифр после точкой." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Убедитесь, что здесь не больше %s цифр перед точкой." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Файл не был отправлен. Проверьте тип кодировки на форме." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Файл не был отправлен." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Отправленный файл пуст." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Убедитесь, что имя этого файла имеет не больше %(max)d букв (сейчас - " +"%(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "Пожалуйста, или отправьте файл, или снимите флажок." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Загрузите корректное изображение. Файл, который вы загрузили - либо не " +"изображение, либо не корректное изображение." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Заблокированный элемент" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "Страница не является 'последней' и не может быть приведена к int." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Неправильная страница (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Неправильное определение разрешения" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Неправильное значение ключа '%s' - объект не существует." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Неверный тип. Ожидалось значение ключа, пришло %s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Объект с %s=%s не существует." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Неправильная гиперссылка - нет подходящего URL" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Неправильная гиперссылка - URL не подходит" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Неправильная гиперссылка из-за ошибки конфигурации" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Неправильная ссылка - объект не существует." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Неверный тип. Ожидалась строка URL, получено %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Неправильные данные." + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Ввод отсутствует" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Нельзя создать новые объект, только существующие объекты могут быть изменены." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Ожидался список объектов." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Не найдено" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Доступ запрещён" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Ошибка приложения на сервере" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Ошибка соединения." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Неверно сформированный запрос." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Неверные данные для аутентификации." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Данные для аутентификации не предоставлены." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "У вас нет разрешения для этого действия." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Метод '%s' не разрешён." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Не удалось соответствовать заголовку принятия для этого запроса" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Не поддерживаемый тип медиа '%s' в запросе." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Запрос был замят" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Будет доступно в течение %d секунд%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Неожиданная ошибка" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Не найдено." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Метод не поддерживается с этого конца." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Неправильные аргументы." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Ошибка при проверке данных" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Ошибка целостности из-за неправильных параметров" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Ошибка предусловия" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Не осталось места для проектов" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s не является списком" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Ошибка в типах фильтров для параметров." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project' должно быть целым значением." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Вы в Тайге" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

Добро пожаловать в " +"Taiga%(product_name)s - инструмент с открытым исходным кодом для управления " +"проектами в стиле Agile

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +"Настроить уведомления электронной почты или " +"отписаться\n" +" • \n" +"Поддержка Taiga\n" +" • \n" +"Связаться с нами" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Следите за нами в Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Скачайте код на GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Обновления" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Обновления" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

комментарий:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Комментарий: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Ошибка доступа хоста" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "Ошибка IP адреса" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "Пользовательская история создана" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "Пользовательская история изменена" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "Пользовательская история удалена" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "ПИ #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Задача создана" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Задача изменена" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Задача удалена" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Задача #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Запрос создан" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Запрос изменён" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Запрос удалён" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Запрос: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Wiki-Страница создана" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Wiki-Страница изменена" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Wiki-Страница удалена" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Wiki-Страница: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Спринт создан" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Спринт изменён" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Спринт удалён" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Спринт: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Нам была нужна хотя бы одна роль" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Необходим дамп" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Неправильный формат дампа" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "ошибка при импорте данных проекта" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "ошибка при импорте ролей" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "ошибка при импорте членства" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "ошибка при импорте списков свойств проекта" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "ошибка при импорте значений по умолчанию свойств проекта" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "ошибка при импорте пользовательских свойств" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "ошибка при импорте спринтов" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "ошибка при импорте запросов" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "ошибка импорта историй от пользователей" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "ошибка импорта эпиков" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "ошибка импорта задач" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "ошибка при импорте вики-страниц" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "ошибка при импорте вики-ссылок" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "ошибка импорта тэгов" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "ошибка импорта хронологии проекта" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "неожиданная ошибка импортирования проекта" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Вы не можете иметь больше частных проектов" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "В этом частном проекте достигнут лимит участников" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Вы не можете иметь больше публичных проектов" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "В этом публичном проекте достигнут лимит участников" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Ошибка создания свалочного файла для проекта" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"Ошибка загрузки дампа {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"ПРИЧИНА:\n" +"-------\n" +"{reason}\n" +"\n" +"ДЕТАЛИ:\n" +"--------\n" +"{details}\n" +"\n" +"ТРАССИРОВКА ОШИБКИ:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Ошибка загрузки дампа" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Ошибка загрузки дампа вашего проекта" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "-- нет детальной информации --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Создана резервная копия проекта

\n" +" Здравствуйте, %(user)s,

\n" +"

Резервная копия проекта %(project)s успешно создана.

\n" +"

Вы можете скачать ее здесь:

\n" +" Скачать резервную копию\n" +"

Этот файл будет удалён %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(user)s,\n" +"\n" +"Дамп для проекта %(project)s успешно сгенерирован. Вы можете скачать его " +"здесь:\n" +"\n" +"%(url)s\n" +"\n" +"Файл будет удалён %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] Дамп вашего проекта успешно сгенерирован" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Здравствуйте, %(user)s,

\n" +"

Экспорт вашего проекта %(project)s был выполнен некорректно.

\n" +"

Системные администраторы Taiga были проинформированы об этом.
" +"Пожалуйста, попробуйте ещё раз или свяжитесь со службой поддержки:\n" +"%(support_email)s

\n" +"

%(signature)s

" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(user)s,\n" +"\n" +"%(error_message)s\n" +"Ваш проект %(project)s не был корректно экспортирован.\n" +"\n" +"Системные администраторы Taiga уведомлены об этом.\n" +"\n" +"Пожалуйста, попробуйте ещё раз или свяжитесь со службой поддержки " +"%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" +"\n" +"%(error_message)s\n" +"

Здравствуйте, %(user)s,

\n" +"

Импорт вашего проекта был выполнен некорректно.

\n" +"

Системные администраторы %(product_name)s были проинформированы об этом." +"
Пожалуйста, попробуйте ещё раз или свяжитесь со службой поддержки:\n" +"%(support_email)s

\n" +"

%(signature)s

\n" +"

Детали ошибки

\n" +"
%(details)s
" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(user)s,\n" +"\n" +"%(error_message)s\n" +"Ваш проект не был корректно экспортирован.\n" +"\n" +"Системные администраторы %(product_name)s уведомлены об этом.\n" +"\n" +"Пожалуйста, попробуйте ещё раз или свяжитесь со службой поддержки " +"%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Дамп проекта импортирован

\n" +"

Здравствуйте, %(user)s,

\n" +"

Дамп вашего проекта успешно импортирован.

\n" +" Перейти к проекту %(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(user)s,\n" +"\n" +"Дамп вашего проекта был успешно импортирован.\n" +"\n" +"Увидеть проект можно здесь: %(project)s\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Дамп вашего проекта импортирован" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" не найдено в этом проекте" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Неправильные данные. Должны быть в формате {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "Содержит неверные специальные поля." + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "Дублированное имя" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" +"Эпик имеет связанную историю из внешнего проекта (%(project)s) и не может " +"быть импортирован." + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Необходима аутентификация" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "имя" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "url иконки" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "веб" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "описание" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Следующий url" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "приложение" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Неверный токен" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "полное имя" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "адрес email" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "комментарий" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "дата создания" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Отзыв

\n" +"

Taiga получила отзывы от %(full_name)s <%(email)s>

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Комментарий

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Дополнительное инфо" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- От: %(full_name)s <%(email)s>\n" +"---------\n" +"- Комментарий:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Дополнительное инфо:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Отзыв от %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "Содержимое не является json " + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Проект не существует" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Плохая подпись" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" +"[{user_name}] ({user_url} \"Просмотреть профиль пользователя {user_name}'s " +"{platform} \") оставившего комментарий в [{platform}#{number}] " +"({comment_url} \"Перейти к комментарию\"):\n" +"\n" +"\"{comment_message}\"" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"Комментарий из {platform}:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Неправильная информация в комментарии к запросу" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Запрос создан [{user_name}]({user_url} \"Просмотреть профиль {user_name} " +"{platform}\") из [{platform}#{number}]({url} \"Перейти к запросу\")." + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Запрос создан из {platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Запрос изменен [{user_name}]({user_url} \"Просмотреть профиль {user_name} " +"{platform}\") из [{platform}#{number}]({url} \"Перейти к запросу\")." + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "Запрос изменен из {platform}." + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Запрос закрыт [{user_name}]({user_url} \"Просмотреть профиль {user_name} " +"{platform}\") из [{platform}#{number}]({url} \"Перейти к запросу\")." + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "Запрос закрыт из {platform}." + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Запрос повторно открыт [{user_name}]({user_url} \"Просмотреть профиль " +"{user_name} {platform}\") из [{platform}#{number}]({url} \"Перейти к " +"запросу\")." + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "Запрос повторно открыт из {platform}." + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Неверная информация о запросе" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "неизвестный пользователь" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} сменил статус с [{platform} commit]({commit_url} \"Просмотрите " +"версию '{commit_id} - {commit_short_message}'\")\n" +"\n" +"- Статус: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"Изменен статус с {platform} версии.\n" +"\n" +"- Статус: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"Этот {type_name} был использован {user_text} в [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "Эта проблема была упомянута в {platform} версии \"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Указанный элемент не существует" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Статус не существует" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Требуется параметр проекта" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Запрос Asana API недействителен" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "Не удалось сделать запрос к Asana API" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Требуется параметр кода" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Ошибка импорта проекта Asana" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Данные аутентификации неверны" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "Отказ сторонних служб" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "Ошибка импорта проекта GitHub" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "Требуется параметр url" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" +"\n" +"Произошла ошибка; вероятно, из-за неподдерживаемой версии Jira.\n" +"Taiga не поддерживает релизы Jira с версии 8.6." + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "Неверный project_type {}" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "Неправильная настройка Jira-сервера." + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "Неверный или истекший token аутентификации " + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Ошибка импорта проекта Jira" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "Ошибка импорта проекта PivotalTracker" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Импортирован проект Asana

\n" +"

Добро пожаловать, %(user)s,

\n" +"

Ваш проект Asana был успешно импортирован.

\n" +"Перейти к проекту: %(project)s\n" +"%(signature)s

" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(user)s,\n" +"\n" +"Ваш Asana проект был успешно импортирован.\n" +"\n" +"Увидеть проект можно здесь: %(project)s\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Ваш Asana проект был импортирован" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Проект GitHub импортирован

\n" +"

Здравствуйте, %(user)s

\n" +"

Ваш проект GitHub был успешно импортирован.

\n" +"Перейти в %(project)s\n" +"

%(signature)s

" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(user)s, \n" +"\n" +"Ваш GitHub проект успешно импортирован.\n" +"\n" +"Перейти к проекту: %(project)s\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +" \n" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] Ваш GitHub проект был импортирован" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Здравствуйте, %(user)s,

\n" +"

Импорт вашего проекта был выполнен некорректно.

\n" +"

Системные администраторы %(product_name)s были проинформированы об этом." +"
Пожалуйста, попробуйте ещё раз или свяжитесь со службой поддержки:\n" +"%(support_email)s

\n" +"

%(signature)s

" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Проект Jira импортирован

\n" +"

Добро пожаловать, %(user)s,

\n" +"

Ваш проект Jira был успешно импортирован.

\n" +"Перейти к проекту: %(project)s\n" +"

%(signature)s

" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(user)s,\n" +"\n" +"Ваш Jira проект был успешно импортирован.\n" +"\n" +"Увидеть проект можно здесь: %(project)s\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Ваш проект Jira был импортирован" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Проект Trello импортирован

\n" +"

Добро пожаловать, %(user)s,

\n" +"

Ваш проект Trello был успешно импортирован.

\n" +"Перейдите к проекту %(project)s\n" +"

%(signature)s

" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(user)s,\n" +"\n" +"Ваш Trello проект был успешно импортирован.\n" +"\n" +"Вы можете просмотреть проект %(project)s здесь:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Ваш проект Trello был успешно импортирован" + +#: taiga/importers/trello/importer.py:57 +#, fuzzy, python-format +#| msgid "Invalid Request: %s at %s" +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "Некорректный запрос: %s на %s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, fuzzy, python-format +#| msgid "Unauthorized: %s at %s" +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "Неавторизованый запрос: %sна %s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, fuzzy, python-format +#| msgid "Resource Unavailable: %s at %s" +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "Источник недоступен: %s на %s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "Ошибка импорта проекта Trello" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Просмотреть проект" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Просмотреть вехи" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Посмотреть эпики" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Просмотреть пользовательские истории" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Просмотреть задачи" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Посмотреть запросы" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Просмотреть wiki-страницы" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Просмотреть wiki-ссылки" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Добавить веху" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Изменить веху" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Удалить веху" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Добавить эпик" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Изменить эпик" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "Комментировать эпик" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Удалить эпик" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Просмотреть пользовательскую историю" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Добавить пользовательскую историю" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Изменить пользовательскую историю" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "Комментировать пользовательскую историю" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Удалить пользовательскую историю" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Добавить задачу" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Изменить задачу" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Комментировать задачу" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Удалить задачу" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Добавить запрос" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Изменить запрос" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "Комментировать запрос" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Удалить запрос" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Создать wiki-страницу" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Изменить wiki-страницу" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Комментировать wiki-страницу" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Удалить wiki-страницу" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Добавить wiki-ссылку" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Изменить wiki-ссылку" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Удалить wiki-ссылку" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Изменить проект" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Удалить проект" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Добавить участника" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Удалить участника" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Управлять значениями проекта" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Управлять ролями" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "Конфиденциально" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "Модули" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Значения по-умолчанию" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "Активность" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "Поклонники" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "владелец" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Сделать публичным" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} успешно переведено в статус публичных." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Сделать приватным" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} успешно переведено в статус приватных." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Список аргументов неполон" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Неправильный формат изображения" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "Неверное название шаблона" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "Неверное описание шаблона" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "Неправильный id пользователя" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "Пользователь не существует" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "Пользователь должен быть участником проекта" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "Дорожка по умолчанию не может быть удалена." + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" +"Вы не можете удалить статус даты выполнения по умолчанию для " +"пользовательской истории." + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "Проект уже имеет сроки выполнения." + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "Вы не можете удалить статус даты выполнения по умолчанию для задачи." + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "Проект уже имеет сроки выполнения задачи." + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "Вы не можете удалить статус даты выполнения по умолчанию для запроса." + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "Проект уже имеет сроки выполнения запроса." + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" +"Чтобы добавить участников в проект, сначала необходимо подтвердить свой " +"email." + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" +"Данный пользователь не может быть удален из следующих проектов, так как это " +"оставит проекты без активного администрирования: {}." + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "comment is required" +msgid "Email is required" +msgstr "необходим комментарий" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"У проекта должен быть владелец и по крайней мере один пользователь должен " +"быть активным администратором" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "У вас нет прав для просмотра." + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "Частичные обновления не поддерживаются" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "Идентификатор объекта не существует" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "Не совпадает идентификатор проекта у объекта и проекта." + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "проект" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "тип содержимого" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "идентификатор объекта" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "изменённая дата" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "приложенный файл" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "устаревшее" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "из комментария" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "порядок" + +#: taiga/projects/attachments/validators.py:46 +#, fuzzy +#| msgid "" +#| "Invalid user story id to move after. The user story must belong to the " +#| "same project, status and swimlane." +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" +"Неверный идентификатор пользовательской истории для перемещения \"после\". " +"Пользовательская история должна принадлежать тому же проекту, статусу и " +"дорожке." + +#: taiga/projects/attachments/validators.py:61 +#, fuzzy +#| msgid "" +#| "Invalid task ids. All tasks must belong to the same project and, if it " +#| "exists, to the same status, user story and/or milestone." +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" +"Неверные идентификаторы задач. Все задачи должны принадлежать одному проекту " +"и, если это возможно, одному статусу, пользовательской истории и/или этапу." + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "Whereby.com" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Специальный" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "Проект заблокирован из-за ошибки при оплате" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "Проект заблокирован администраторами" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "Проект заблокирован, потому что владелец ушёл" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "Этот проект будет заблокирован при удалении" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +"%(full_name)s " +"сделал запись в проект(ы) %(project_name)s" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +"Вы получили это сообщение, так как вы назначены администратором проектов " +"%(project_name)s. Если вы не хотите, чтобы участники сообщества Taiga " +"связывались с вами по поводу вашего проекта, пожалуйста, обновите настройки вашего проекта, " +"чтобы предотвратить такие контакты. Это не повлияет на обычные контакты " +"между участниками проекта." + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s сделал(-а) запись в проект(-ы) %(project_name)s\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +"Вы получили это сообщение, так как вы были указаны администратором проекта " +"%(project_name)s. Если вы не хотите, чтобы участники сообщества Taiga " +"связывались с вами по поводу вашего проекта, пожалуйста, обновите настройки " +"проекта в %(project_settings_url)s чтобы предотвратить подобные контакты. " +"Это не повлияет на обычные контакты между участниками проекта. \n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s отправил(-а) сообщение в проект %(project_name)s\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Текст" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Многострочный текст" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "Большой текст" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Дата" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "Выпадающий список" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "Флажок" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "Число" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "тип" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "значения" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "эпик" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "пользовательская история" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "задача" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "запрос" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Это имя уже используется." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "дедлайн" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "причина дедлайна" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "У вас нет разрешения для установки статуса этому эпику" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "Ссылка" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "cтатус" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "порядок эпиков" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "тема" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "цвет" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "назначено" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "является требованием клиента" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "является требованием команды" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "пользовательские истории" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "внешняя ссылка" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "Не существует эпика с таким идентификатором" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "необходим комментарий" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "удаленные комментарии не могут быть отредактированы" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Комментарий уже был удалён" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Комментарий не удалён" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Изменить" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Создать" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Удалить" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "очки для роли %(role)s" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "от" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "кому" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Добавлено новое вложение" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Вложение обновлено" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "устаревшее" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "не устаревшее" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Удалённое вложение" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "добавлено" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "удалено" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Не назначено" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "Не установлено" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-удалено-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "кому:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "от:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Добавлено" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Изменено" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Удалено" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "добавлено:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "удалено:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "От:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Кому:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "содержимое" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "Заметка о блокировке" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "спринт" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" +"У вас нет прав для того чтобы установить такой спринт для этого запроса" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" +"У вас нет прав для того чтобы установить такой статус для этого запроса" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" +"У вас нет прав для того чтобы установить такую важность для этого запроса" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" +"У вас нет прав для того чтобы установить такой приоритет для этого запроса" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "У вас нет прав для того чтобы установить такой тип для этого запроса" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "важность" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "приоритет" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "веха" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "дата завершения" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "Это недопустимая веха для проекта" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "Все запросы должны принадлежать тому же проекту." + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Лайк" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Лайки" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "ссылочное имя" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "предполагаемая дата начала" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "предполагаемая дата завершения" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "закрыто" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "доступность" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" +"Предполагаемая дата начала должна предшествовать предполагаемой дате " +"завершения." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "Не существует вехи с таким идентификатором" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "Все пользовательские истории должны быть из того же проекта" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "заблокировано" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "Требуется параметр ref" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "требуется параметр project или project__slug" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "Требуется параметр запроса 'moveTo'" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" +"Невозможно установить для дорожки значение \"Нет\", если есть доступные " +"дорожки" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "параметр '{param}' является обязательным" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "параметр 'project' является обязательным" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "Пользователь должен быть участником проекта." + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "электронная почта" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "создано" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "идентификатор" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "дополнительный текст к приглашению" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "порядок пользователей" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "Этот пользователем уже является участником проекта" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "удалить статус эпика" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "статусы ПИ по умолчанию" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "очки по умолчанию" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "статус задачи по умолчанию" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "приоритет по умолчанию" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "важность по умолчанию" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "статус запроса по умолчанию" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "тип запроса по умолчанию" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "Дорожка по умолчанию" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "лготип" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "участники" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "общее количество вех" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "очки истории" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "активный контакт" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "активная панель эпиков" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "активная панель списка задач" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "активная панель kanban" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "активная wiki-панель" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "панель активных запросов" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "система видеоконференций" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "дополнительные данные системы видеоконференций" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "шаблон для создания" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "личное" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "права анонимов" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "права пользователя" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "особенность" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "ищут людей" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "поиск примечаний людей" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "токен передачи проекта" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "заблокированный код" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "дата и время обновления" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "количество" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "фанатов на прошлой недели " + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "фанатов в прошлом месяце" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "фанатов в прошлом году" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "активность за неделю" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "активность за месяц" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "активность за год" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "конфигурация модулей" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "архивировано" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "ограничение на активную работу" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "значение" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "по умолчанию" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "дней до" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "Статус пользовательской истории" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "роль владельца по умолчанию" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "параметры по умолчанию" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "статусы эпика" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "статусы ПИ" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "дедлайны ПИ" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "очки" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "статусы задач" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "дедлайны задач" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "статусы запросов" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "типы запросов" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "дедлайны запросов" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "приоритеты" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "степени важности" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "роли" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "атрибуты эпика" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "атрибуты ПИ" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "атрибуты задачи" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "атрибуты проблемы" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Вовлеченные" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Все" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Никаких" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "Назначен" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "Обозначено" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "Добавлен как наблюдатель." + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "Добавлен как участник" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "Комментарий" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "Обозначено в комментарии" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "дата и время создания" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "записи истории" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "уведомить пользователей" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Просмотренные" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Уведомление существует для данных пользователя и проекта" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Неверное значение для уровня уведомлений" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" +"\n" +"

Эпик обновлён

\n" +"

Здравствуйте, %(user)s,
%(changer)s обновил эпик в %(project)s\n" +"

Эпик #%(ref)s %(subject)s

\n" +" Просмотреть эпик\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"Эпик обновлён\n" +"Здравствуйте, %(user)s, %(changer)s обновил эпик в %(project)s\n" +"Просмотреть эпик #%(ref)s %(subject)s по ссылке %(url)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Обновил эпик #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Создан новы эпик

\n" +"

Здравствуйте, %(user)s,
%(changer)s создал новый эпик в " +"%(project)s

\n" +"

Эпик #%(ref)s %(subject)s

\n" +" Просмотреть эпик\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Создан новый эпик\n" +"Здравствуйте, %(user)s, %(changer)s создал новый эпик в %(project)s\n" +"Просмотреть эпик #%(ref)s %(subject)s можно здесь %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Создал эпик #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Эпик удалён

\n" +"

Здравствуйте, %(user)s,
%(changer)s удалил эпик для %(project)s \n" +"

Эпик #%(ref)s %(subject)s

\n" +"

%(signature)s

" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Эпик удален\n" +"Здравствуйте, %(user)s, %(changer)s удалил эпик в %(project)s\n" +"Эпик #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Удален эпик #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" +"\n" +"

Запрос в проекте %(project)s был обновлен

\n" +"

Здравствуйте, %(user)s,
%(changer)s обновил запрос:

\n" +"

#%(ref)s%(subject)s

\n" +" Просмотреть запрос \n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" +"\n" +"Запрос в проекте %(project)s был изменен \n" +"\n" +"Здравствуйте, %(user)s. %(changer)s обновил запрос:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Просмотреть запрос: %(url)s \n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Обновлён запрос #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Новый запрос был создан в рамках проекта %(project)s

\n" +"

Здравствуйте, %(user)s.
%(changer)s создал новый запрос:

\n" +"

#%(ref)s %(subject)s

\n" +"Просмотреть запрос \n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Новый запрос был создан в рамках проекта %(project)s\n" +"Здравствуйте, %(user)s. %(changer)s создал новый запрос:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Просмотреть запрос: %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +" \n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Добавлен запрос #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Запрос по проекту %(project)s удален

\n" +"

Здравствуйте, %(user)s,
%(changer)s удалил запрос:

\n" +"

#%(ref)s%(subject)s

\n" +"

%(signature)s

" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Запрос по проекту %(project)s удален\n" +"\n" +"Здравствуйте, %(user)s, %(changer)s удалил запрос:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Удалён запрос #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" +"\n" +"

Спринт в проекте %(project)s был обновлен

\n" +"

Здравствуйте, %(user)s,
%(changer)s обновил спринт:

\n" +"

%(name)s

\n" +" Просмотреть спринт \n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" +"\n" +"Спринт проекта %(project)s был изменен \n" +"\n" +"Здравствуйте, %(user)s. %(changer)s обновил спринт:\n" +"\n" +"%(name)s\n" +"\n" +"Просмотреть спринт: %(url)s \n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Обновлён спринт \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Новый спринт создан по проекту %(project)s

\n" +"

Здравствуйте, %(user)s.
%(changer)s создал новый спринт:

\n" +"

%(name)s

\n" +"Просмотреть спринт \n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Новый спринт был создан в рамках проекта %(project)s\n" +"\n" +"Здравствуйте, %(user)s. %(changer)s создал новый спринт:\n" +"\n" +"%(name)s\n" +"\n" +"Просмотреть спринт: %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +" \n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Добавлен спринт \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Спринт в рамках проекта %(project)s удален

\n" +"

Здравствуйте, %(user)s,
%(changer)s удалил спринт:

\n" +"

%(name)s

\n" +"

%(signature)s

" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Спринт по проекту %(project)s удален\n" +"\n" +"Здравствуйте, %(user)s. %(changer)s удалил спринт:\n" +"\n" +"#%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] Удалён спринт \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" +"\n" +"

Задача проекта %(project)s была обновлена

\n" +"

Здравствуйте, %(user)s.
%(changer)s обновил задачу:

\n" +"

#%(ref)s%(subject)s

\n" +" Просмотреть задачу \n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" +"\n" +"Задача по проекту %(project)s была изменена \n" +"\n" +"Здравствуйте, %(user)s. %(changer)s обновил задачу:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Просмотреть задачу: %(url)s \n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Обновлена задача #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Новая задача была создана в рамках проекта %(project)s

\n" +"

Здравствуйте, %(user)s.
%(changer)s создал новую задачу:

\n" +"

#%(ref)s %(subject)s

\n" +"Просмотреть задачу \n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Новая задача была создана в рамках проекта %(project)s\n" +"Здравствуйте, %(user)s. %(changer)s создал новую задачу:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Просмотреть задачу: %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +" \n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Создана задача #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Задача по проекту %(project)s удалена

\n" +"

Здравствуйте, %(user)s,
%(changer)s удалил задачу:

\n" +"

#%(ref)s%(subject)s

\n" +"

%(signature)s

" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Задача по проекту %(project)s удалена\n" +"\n" +"Здравствуйте, %(user)s, %(changer)s удалил задачу:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Удалена задача #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" +"\n" +"

Пользовательская история в %(project)s была изменена

\n" +"

Здравствуйте, %(user)s.
%(changer)s обновил пользовательскую историю:\n" +"

#%(ref)s%(subject)s

\n" +" Просмотреть пользовательскую историю " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" +"\n" +"Пользовательская история в %(project)s была изменена \n" +"\n" +"Здравствуйте, %(user)s. %(changer)s обновил пользовательскую историю:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Просмотреть пользовательскую историю %(url)s \n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Изменена ПИ #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Новая пользовательская история была создана в рамках %(project)s

\n" +"

Здравствуйте, %(user)s.
%(changer)s создал новую пользовательскую " +"историю:

\n" +"

#%(ref)s %(subject)s

\n" +"Просмотреть пользовательскую историю \n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Новая пользовательская история была создана в рамках %(project)s\n" +"Здравствуйте, %(user)s. %(changer)s создал новую пользовательскую историю:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"Просмотреть пользовательскую историю %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +" \n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Создана ПИ #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +" Пользовательская история по проекту %(project)s удалена

\n" +"

Здравствуйте, %(user)s,
%(changer)s удалил историю для

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Пользовательская история по проекту %(project)s удалена\n" +"\n" +"Здравствуйте, %(user)s, %(changer)s удалил историю\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] Удалена ПИ #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" +"\n" +"

Изменена wiki-страница по проекту %(project)s

\n" +"

Здравствуйте, %(user)s,
%(changer)s изменил wiki-страницу

\n" +"

%(page)s

\n" +" Посмотреть " +"wiki-страницу \n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" +"\n" +"Wiki-страница по проекту %(project)s изменена\n" +"\n" +"Здравствуйте, %(user)s, %(changer)s изменил wiki-страницу: \n" +"\n" +"%(page)s\n" +"\n" +"Просмотреть wiki-страницу: %(url)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Обновлена вики-страница \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Создана новая wiki-страница

\n" +"

Здравствуйте, %(user)s,
%(changer)s создал новую wiki-страницу " +"для проекта %(project)s

\n" +"

Wiki-страница %(page)s

\n" +" Просмотреть wiki-страницу \n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Создана новая Wiki-страница в %(project)s\n" +"\n" +"Здравствуйте, %(user)s, %(changer)s создал новую wiki-страницу: \n" +"\n" +"%(page)s\n" +"\n" +"Просмотреть wiki-страницу: %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Создана вики-страница \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Wiki-страница удалена в %(project)s

\n" +"

Здравствуйте, %(user)s,
%(changer)s удалил Wiki-страницу

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +" Wiki-страница удалена в %(project)s\n" +"\n" +" Здравствуйте, %(user)s,%(changer)s удалил Wiki-страницу\n" +" %(page)s\n" +"\n" +"---\n" +" %(signature)s \n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] Удалена вики-страница \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "наблюдатели содержат неправильных пользователей" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "Версия должна быть целым значением" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "Значение версии некорректно" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Версия не соответствует текущей" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "версия" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" +"Вы не можете покинуть проект, если вы владелец или нет других администраторов" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "Токен не подходит ни под одно корректное приглашение." + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The user doesn't exist" +msgid "User does not exist." +msgstr "Пользователь не существует" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Этот пользователь уже является участником данного проекта" + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "новый email адрес" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Проект без владельца" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "Вы достигли лимита участников для частного проекта" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "Вы достигли лимита участников для публичного проекта" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "Вы достигли текущего лимита пользователей, ожидающих членства" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "Задача # %(ref)s" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Будущий спринт" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Окончание проекта" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Неверный токен" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Срок действия токена истёк" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "Временная шкала" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "Эпики" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "Бэклог" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "Запросы" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "КомандаWiKi" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "У вас нет доступа к этому разделу" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" +"Неверный тэг '{value}'. Цвет должен быть в шестнадцатеричном формате или " +"отсутствовать." + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"Неверный тэг '{value}'. Это должно быть имя или пара '[\"name\", \"hex color/" +"\" | null]'." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "Неверный тэг '{value}'. Это должно быть имя тэга." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "теги" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "цвета тэгов" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Такой тэг уже существует." + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "Этот некорректный шестнадцатеричный формат для цвета" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "Тэг не существует" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "У вас нет прав, чтобы назначить этот спринт для этой задачи." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" +"У вас нет прав, чтобы назначить эту историю от пользователя этой задаче." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "У вас нет прав, чтобы установить этот статус для этой задачи." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "порядок ПИ" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "порядок панели задач" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "- иокаин" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "Неверный id вехи" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "Неверный id статуса задачи" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "Неверный id пользовательской истории" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" +"Неверный id статуса задачи. Статус должен принадлежать тому же проекту." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" +"Неверный id пользовательской истории. Пользовательская история должна " +"принадлежать тому же проекту." + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "Неверный id вехи. Веха должна принадлежать тому же проекту." + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" +"Неверные идентификаторы задач. Все задачи должны принадлежать одному проекту " +"и, если это возможно, одному статусу, пользовательской истории и/или этапу." + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "Все задачи должны быть из одного проекта" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "некто" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" +"\n" +"

Вы приглашены в %(product_name)s!

\n" +"

Здравствуйте! %(full_name)s пригласил вас присоединиться к проекту " +"%(project)s в системе %(product_name)s.
Taiga - это бесплатный " +"инструмент с открытым исходным кодом для управления проектами в стиле Agile." +"

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

А теперь несколько слов от добрых братьев или сестёр,
" +"которые были столь любезны, что пригласили вас

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Принять приглашение в Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Принять приглашение" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" +"\n" +"Вы или кто-то, известный вам, пригласили вас в %(product_name)s\n" +"\n" +"Приветствую! %(full_name)s пригласил вас присоединиться к проекту " +"%(project)s в системе%(product_name)s. \n" +"Taiga - это бесплатный инструмент с открытым исходным кодом для управления " +"проектами в стиле Agile.\n" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"А теперь несколько слов от добрых братьев или сестёр, которые были столь " +"любезны, что пригласили вас:\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Принять приглашение в Тайгу можно перейдя по ссылке:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Приглашение присоединиться к проекту '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Вы были добавлены в проект

\n" +"

Здравствуйте, %(full_name)s,
вы были добавлены в проект " +"%(project)s

\n" +" Перейти " +"к проекту \n" +"

%(signature)s

\n" +" " + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Вы были добавлены в проект\n" +"Здравствуйте, %(full_name)s, вы были добавлены в проект %(project)s\n" +"\n" +"Посмотреть проект можно здесь: %(url)s\n" +"\n" +"--\n" +"%(signature)s\n" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] Добавлены к проекту '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Привет %(old_owner_name)s,

\n" +"

%(new_owner_name)s подтвердил ваше запрос и будет новым владельцем для " +"\"%(project_name)s\".

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s сказал:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

С этого момента Ваш статус будет администратор.

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"

Приветствую, %(old_owner_name)s,

\n" +"

%(new_owner_name)s принял ваше предложение и станет новым владельцем " +"проекта для \"%(project_name)s\".

\n" +"\n" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s сказал:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"С этого момента Ваш статус будет администратор.\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] Передача проекта подтверждена\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Приветствую, %(owner_name)s,

\n" +"

%(rejecter_name)s отклонил ваше предложение и не станет новым владельцем " +"проекта для \"%(project_name)s\".

" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s сказал:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

Если Вы хотите, Вы можете попробовать передать собственность другой " +"персоне.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "Запрос передан другой персоне" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"Приветствую, %(owner_name)s,\n" +"%(rejecter_name)s отклонил ваше предложение и не станет новым владельцем " +"проекта для \"%(project_name)s\".\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s сказал:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"Если Вы хотите, Вы можете попробовать передать собственность другой " +"персоне.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "Запрос передан другой персоне:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] Передача проекта отменена\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Приветствую, %(owner_name)s,

\n" +"

%(requester_name)s запрашивает возможность стать владельцем проекта для " +"%(project_name)s.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

Пожалуйста, нажмите \"Продолжить\" если вы хотите начать передачу проекта " +"из панели администратора.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Продолжить" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" +"\n" +"Приветствую, %(owner_name)s,\n" +"%(requester_name)s запрашивает возможность стать владельцем проекта для " +"%(project_name)s.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"Пожалуйста, перейдите в настройки проекта, если Вы хотите начать передачу " +"проекта из панели администратора.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Перейдите в настройки проекта:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s] Запрос передачи проекта\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

Привет %(receiver_name)s,

\n" +"

%(owner_name)s, текущий владелец \"%(project_name)s\", хотел что бы Вы " +"стали новым владельцем проекта.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s сказал:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

Пожалуйста, нажмите \"Продолжить\", для принятия или отклонения " +"предложения

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"Привет %(receiver_name)s,\n" +"%(owner_name)s, владелец \"%(project_name)s\", хотел что бы Вы стали новым " +"владельцем проекта.\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s сказал:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"Пожалуйста, пройдите по следующей ссылке для принятия или отклонения " +"предложения.

\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "Подтвердите или отклоните передачу владения проектом:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s] Предложение передачи проекта\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Agile бэклог продукта в Scrum - это приоритезированный список пожеланий, " +"содержащий короткие описания всего функционала который должен быть в " +"продукте. При использовании Scrum, нет нужды начинать проект с длинного, " +"заранее составленного списка абсолютно всех требований. Scrum бэклог " +"продукта может расти и изменяться в ходе того как становится всё больше " +"известно о продукте и его пользователях." + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban - это метод организации работы со знаниями, особое внимание уделяющий " +"моментальной передаче информации без лишней нагрузки членов команды. При " +"этом подходе весь процесс, от создания задачи до её отправки заказчику, " +"отображается для участников в удобном виде и члены команды могут брать себе " +"задачи из очереди." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Новая" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Готово" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "В процессе" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Можно проверять" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Завершена" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Архивирована" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Закрыта" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Требуются подробности" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Отложено" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Отклонена" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Ошибка" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Вопрос" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Улучшение" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Низкий" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Обычный" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Высокий" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Список пожеланий" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Низкий" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Важный" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Критический" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "Юзабилити" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Дизайнер" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Фронтенд разработчик" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Бэкенд разработчик" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Владелец продукта" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Заинтересованная сторона" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"У вас нет прав чтобы установить спринт для этой пользовательской истории." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" +"У вас нет прав чтобы установить статус для этой пользовательской истории." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" +"У вас нет прав чтобы установить эту дорожку для этой пользовательской " +"истории." + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "Неверный id роли '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "Неверный идентификатор точки '{points_id}'" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Сконвертировано в пользовательскую историю #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "роль" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "порядок списка задач" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "порядок спринтов" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "порядок kanban" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "дата окончания" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "назначенные пользователи" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "создано из запроса" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "создано из задачи" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "связано с задачей" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "дорожка" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Не существует пользовательской истории с таким идентификатором" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"Неверный id статуса пользовательской истории. Статус должен принадлежать " +"тому же проекту." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" +"Неверный идентификатор дорожки. Дорожка должна принадлежать тому же проекту." + +#: taiga/projects/userstories/validators.py:130 +#, fuzzy +#| msgid "" +#| "Invalid user story id to move after. The user story must belong to the " +#| "same project, status and swimlane." +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"Неверный идентификатор пользовательской истории для перемещения \"после\". " +"Пользовательская история должна принадлежать тому же проекту, статусу и " +"дорожке." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "Вы не можете использовать значения \"после\" и \"до\" одновременно." + +#: taiga/projects/userstories/validators.py:153 +#, fuzzy +#| msgid "" +#| "Invalid user story id to move before. The user story must belong to the " +#| "same project, status and swimlane." +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"Неверный идентификатор пользовательской истории для перемещения \"после\". " +"Пользовательская история должна принадлежать тому же проекту, статусу и " +"дорожке." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" +"Неверные идентификаторы пользовательских историй. Все пользовательские " +"истории должны принадлежать тому же проекту." + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"Неверный идентификатор пользовательской истории для перемещения \"после\". " +"Пользовательская история должна принадлежать тому же проекту, статусу и " +"дорожке." + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"Неверный идентификатор пользовательской истории для перемещения \"после\". " +"Пользовательская история должна принадлежать тому же проекту, статусу и " +"дорожке." + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Не существует проекта с таким идентификатором" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "Пользователь уже является участником проекта" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "неверная операция" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Неверная роль для этого проекта" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "Пользователь должен являться существующим контактом" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "Владелец проекта должен быть администратором" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" +"По крайней мере один пользователь должен быть администратором для этого " +"проекта" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "Неверный id роли. Роль должна принадлежать тому же проекту." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Параметры по умолчанию" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Статусу пользовательских историй" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Очки" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Статусы задачи" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Статусы запроса" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Типы запроса" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Приоритеты" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Степени важности" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Роли" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Голоса" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Голосовать" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "параметр 'content' является обязательным" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "параметр 'project_id' является обязательным" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "последний отредактировавший" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "идентификатор экземпляра" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Свертесть с историей API для получения изменений" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Участник проекта" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Участники проекта" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Владелец проекта" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Владельцы проекта" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "участники" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Окончание проекта" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Окончание проекта" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Этот email уже используется" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "Неверный email" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Неверное имя пользователя или e-mail" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "Письмо успешно отправлено!" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Поле \"текущий пароль\" является обязательным" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Поле \"новый пароль\" является обязательным" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "Некорректная длина пароля, требуется как минимум 6 символов" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Неверно указан текущий пароль" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "Неверно, вы уверены что токен правильный и не использовался ранее?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Неверно, вы уверены что токен правильный?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "Email адрес уже проверен" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "Не удалось проверить этот адрес электронной почты" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "Письмо успешно отправлено!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "статус суперпользователя" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "Выбранный пользователь имеет все разрешения, ему не чего назначит." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "имя пользователя" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "Обязательно. 30 символов или меньше. Буквы, числа и символы /./-/_" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Введите корректное имя пользователя." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "активный" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "Выбранный пользователь активен. Отменить выбор для удаления аккаунта." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "статус персонала" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "Определяет, может ли пользователь войти на данный сайт администратора." + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "биография" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "фотография" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "когда присоединился" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "когда присоединился" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "принятые условия" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "новые условия прочитаны" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "язык по умолчанию" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "тема по умолчанию" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "временная зона по умолчанию" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "установить цвета для тэгов" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "email токен" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "новый email адрес" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "максимальное число частных проектов" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "максимальное число публичных проектов" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "максимальное число участников для каждого частного проекта" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "максимальное число участников для каждого публичного проекта" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "разрешения" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Имя пользователя или пароль не соответствуют пользователю." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Измените email

\n" +"

Здравствуйте, %(full_name)s,
пожалуйста, подтвердите свой " +"email

\n" +" Подтвердить " +"email \n" +"

Вы можете проигнорировать это сообщение, если вы не запрашивали " +"изменения email

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(full_name)s, подтвердите, пожалуйста, свой email\n" +"\n" +"%(url)s\n" +"\n" +"Вы можете проигнорировать это сообщение, если вы не запрашивали изменения " +"email.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Изменить e-mail" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Восстановите пароль

\n" +"

Здравствуйте, %(full_name)s,
вы запрашивали восстановление " +"пароля

\n" +" Восстановите свой пароль \n" +"

Вы можете проигнорировать это сообщение, если не запрашивали " +"восстановление пароля

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(full_name)s, вы запросили восстановление пароля\n" +"\n" +"%(url)s\n" +"\n" +"Вы можете проигнорировать это сообщение, если не запрашивали восстановление " +"пароля.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Восстановление пароля" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" +"\n" +"

Пожалуйста, подтвердите свой email

\n" +"Подтвердите " +"email" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" +"\n" +"\n" +"

Спасибо за регистрацию в %(product_name)s

\n" +"

Мы очень рады, что вы присоединились к нашему растущему сообществу " +"профессионалов, выбирающих революционный путь в организации своей работы, и " +"надеемся, вам понравится с нами

\n" +"

Есть вопросы? Сообщите нам, и мы постараемся протянуть вам руку помощи: " +"%(support_email)s

\n" +"

%(signature)s

\n" +"" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" Вы можете удалить свой аккаунт посредством клика " +"сюда\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" +"\n" +"Спасибо за регистрацию в %(product_name)s\n" +"\n" +"Мы очень рады, что вы присоединились к нашему растущему сообществу " +"профессионалов, выбирающих революционный путь в организации своей работы, и " +"надеемся, вам понравится с нами.\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" +"\n" +"Пожалуйста, подтвердите свой email: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" +"\n" +"Есть вопросы? Если вам нужна помощь, дайте нам знать: %(support_email)s.\n" +"\n" +"--\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Вы можете удалить свой аккаунт из этого сервиса: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Вы в Тайге!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Подтвердите email

\n" +"

Здравствуйте, %(full_name)s,
пожалуйста, подтвердите свой " +"email

\n" +" Подтвердить " +"email\n" +"

Вы можете проигнорировать это сообщение, если не запрашивали " +"изменения email

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Здравствуйте, %(full_name)s, подтвердите, пожалуйста, свой email\n" +"\n" +"%(url)s\n" +"\n" +"Вы можете проигнорировать это сообщение, если вы не запрашивали изменения " +"email.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "[Taiga] Проверка email" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "невалидный" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Неверное имя пользователя. Попробуйте другое." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "Читай новые правила они должны быть правильными'" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"Дублирующий ключ, значение должно быть уникальны. Ключ '{}' уже существует." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "ключ" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "Секретный ключ" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "код статуса" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "данные запроса" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "заголовки запроса" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "данные ответа" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "заголовки ответа" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "длительность" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "Запрещённый IP адрес" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "Неверный id вехи. Веха должна принадлежать тому же проекту." + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "" +#~ "Неверные идентификаторы пользовательской истории. Все истории должны " +#~ "принадлежать одному проекта и, если это возможно, одному статусу и этапу." + +#~ msgid "Personal info" +#~ msgstr "Личные данные" + +#~ msgid "Permissions" +#~ msgstr "Права доступа" + +#~ msgid "Restrictions" +#~ msgstr "Ограничения" + +#~ msgid "Important dates" +#~ msgstr "Важные даты" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/sr/LC_MESSAGES/django.po b/taiga/locale/sr/LC_MESSAGES/django.po new file mode 100644 index 000000000..02e21f759 --- /dev/null +++ b/taiga/locale/sr/LC_MESSAGES/django.po @@ -0,0 +1,4783 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-02-12 12:14+0000\n" +"Last-Translator: David Barragán \n" +"Language-Team: Serbian (http://www.transifex.com/taiga-agile-llc/taiga-back/" +"language/sr/)\n" +"Language: sr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "" + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "" + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "" + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "" + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "" + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "" + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "" + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "" + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "" + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "" + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "" + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "" + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/sv/LC_MESSAGES/django.po b/taiga/locale/sv/LC_MESSAGES/django.po new file mode 100644 index 000000000..1351275cc --- /dev/null +++ b/taiga/locale/sv/LC_MESSAGES/django.po @@ -0,0 +1,4851 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# 03992e16f8df6e39b9d1cc0ff635887e, 2017 +# Harald Indgul , 2015 +# 03992e16f8df6e39b9d1cc0ff635887e, 2017 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-02-26 11:31+0000\n" +"Last-Translator: Taiga Dev Team \n" +"Language-Team: Swedish (http://www.transifex.com/taiga-agile-llc/taiga-back/" +"language/sv/)\n" +"Language: sv\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "Invalid inloggningstyp" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "Hittade inte" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "Fel roll for projektet" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "Textsträngen är ogiltig" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "Felaktigt användarnamn" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Kräver färre än 255 tecken. Kan vara tecken, nummer och /./-/_." + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Användarnamnet används redan" + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "E-postadressen används redan" + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Användaren finns redan." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "användare" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "Textsträngen är ogiltig" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token has no id" +msgstr "Textsträngen är ogiltig" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "Textsträngen är ogiltig" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Fältet är obligatoriskt." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Felaktigt värde. " + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' värdet måste vara sann eller falskt. " + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Skriv in ett giltigt 'slugg' som består av bokstäver, nummer, understreck " +"och bindestreck." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "Välj korrekt. %(value)s är inte ett giltigt val. " + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Din e-post domän är inte tillåten" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Skriv in en giltig e-postadress" + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "Felaktigt datumformat. Använd ett av dessa formaten istället: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "Tidsdatum har fel format. Bruk ett av dessa formaten istället: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "Felaktigt tidsformat. Bruk ett av dessa formaten istället: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Skriv ett helt nummer." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "Försäkra dig om att värdet är mindre eller lika med %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Försäkra dig om att värdet är större eller lika med %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" värde måste vara flyttal." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Skriv in ett nummer." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Försäkra dig om att det inge är mera än %s siffror i totalen. " + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Försäkra dig om att det inte är mera än %s decimaler." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" +"Försäkra dig om det inte är mera än %s siffror till vänster om " +"decimalpunkten." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Inga filer skickades. Check kodningstypen på formularet. " + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Skickade ingen fil. " + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Den insända filen är tom. " + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Försäkra dig om att filnamnet har som mest %(max)d tecken (det har " +"%(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" +"Vänligen lämna in en fil eller kontrollera kryssrutan för klar, inte båda." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Ladda upp en giltig bild. Filen du laddade upp var antingen inte en bild " +"eller en skadad bild." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Blockerat element" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" +"Sidan är inte \"sist\", och inte heller kan den omvandlas till ett heltal." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Felaktig sida (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Ogiltigt definition för behörighet." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Ogiltigt paket '%s' - objektet existerar inte." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Ogiltigt typ. Förväntad paketvärde, mottaget %s. " + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Objekt med %s=%s existerar inte. " + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Ogiltigt länkadress - Inga länkar passar." + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Ogiltigt länkadress - Felaktig matchning av länkar." + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Felaktig länk förorsakad av et konfigurationsfel. " + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Fel länk - objekten existerar inte. " + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Felaktigt typ. Förväntad länksträng, mottagit %s. " + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Felaktigt data" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Inga indata" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" +"Det går inte att skapa ett nytt objekt, endast befintliga poster uppdateras." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Förväntad lista på poster." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Hittade inte" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Du har inte behöriget" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Serverprogramfel." + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Felaktigt förbindelse." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Felaktigt begäran" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Felaktiga autentiseringsreferenser " + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Autentiseringsuppgifter lämnades inte." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Du har inte behörigheter för att utföra denna åtgärd. " + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Metoden '%s' är inte tillåtet. " + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "Det gick inte att tillgodose begäran på Accept-huvudet" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "Mediatypen '%s' du begär stöds inte. " + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "Begäran blev strypt." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "Förväntas bli tillgängligt inom %d second%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Oväntat fel" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Hittade inget" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Metoden stöds inte för denna slutpunkten." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Fel argument." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Datavalideringsfel" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Integritetsfel för felaktiga eller ogiltiga argument" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Förutsättningsfel" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Det finns inte plats för fler projekt." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Fel i filterparametertyper." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'Projektet\" måste vara ett heltal." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Du har blivit Taiganiserad" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Följ oss på Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "Få koden på GitHub" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Uppdateringar" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Uppdateringar" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Vi behöver minst en roll" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Behöver en hämtningsfil" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Invalid hämtningsfilformat" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "fel vid import av projektdata" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "fel vid importering av roller" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "fel vid import av medlemskap" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "fel vid import av en lista på projektegenskaper" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "fel vid import av anpassade egenskaper" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "felaktig import av sprintar" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "fel vid import av ärenden" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "fel vid import av användarhistorier" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "fel vid import av uppgifter" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "vel vid import av wiki-sidor" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "fel vid import av wiki-länkar" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "fel vid importering av taggar" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "fel vid importering av tidslinje" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Fel vid skapandet av projektkopia" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Feil vid hämtning av projektkopia" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] Din projekthämtning blev utfört" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Ditt projekt importerades korrekt" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" gick inte att hitta för det här projektet" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Felaktigt innehåll. Det måste vara {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Verifiering krävs" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "namn" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "Ikonlänk" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "Internet" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "beskrivning" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Nästa länk" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "program" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Felaktig förekomst. " + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "hela namnet" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "e-postadress" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "kommentera" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "skapad datum" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Extra information" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Extra information: " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Projektet existerar inte" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Dålig signatur" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Felaktigt kommentarinformation för ärendet" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Felaktig ärendeinformation" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Referenselementet existerar inte" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Statusen existerar inte" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Visa projekt" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Visa milstolper" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Visa användarhistorier" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Visa uppgifter" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Visa ärenden" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Visa wiki-sidor" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Visa wiki-länkar" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Lägg till milstolpe" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Modifiera milstolpe" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Ta bort milstolpe" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Visa användarhistorie" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Lägg till användarhistorie" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Modifiera användarhistorien" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Ta bort användarhistorien" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Lägg till uppgift" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Modifiera uppgift" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Ta bort uppgift" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Lägg till ärende" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Modifiera ärende" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Ta bort ärende" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Lägg till en wiki-sida" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Modifiera wiki-sida" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Ta bort wiki-sida" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Lägg till wiki-länk" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Modifiera wiki-link" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Ta bort wiki-länk" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Mofifiera projekt" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Ta bort projekt" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Lägg till medlem" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Ta bort medlem" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Administrera projektvärden" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Administratorroller" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "ägare" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Felaktiga argument" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Felaktigt bildformat" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "Fältet är obligatoriskt." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "Delvisa uppdateringar stöds inte. " + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "projekt" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "innehållstyp" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "objekt-ID" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "ändrad datum" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "bifogad fil" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "undviks" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "sortera" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Anpassa" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Text" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Text med flera rader" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Datum" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "typ" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "värden" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "Användarhistorie" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "uppgift" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "Ärende" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Existerar redan med samma namn. " + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "status" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "titel" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "färg" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "Tilldelad till" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "är ett beställarkrav" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "är ett krav från arbetsgruppen" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "extern referens" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Kommentaren är redan borttagit. " + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Kommentaren är inte borttagit" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Ändra" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Skapa" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Ta bort" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s rollpoäng" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "från" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "till " + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Lagt till ny bilaga" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Uppdaterad bilaga" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "borttagen" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "inte borttagen" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Bilaga borttagen" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "lagt till" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "borttaget" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Ej tilldelad" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-raderad-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "till:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "från:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Lagt till" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Ändrad" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Raderad" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "lagt till:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "borttaget:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Från:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Till:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "innehåll" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "blockerad notering" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "sprint" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Du har inte behörighet att sätta sprinten till det här ärendet." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Du har inte behörighet att sätta status till det här ärendet. " + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "Du har inte behörighet att sätta allvarsgrad till det här ärendet. " + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "Du har inte behörighet att sätta prioriteten för det här ärendet. " + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Du har inte behörighet att lägga till typen till ärendet. " + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "Allvarsgrad" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "prioritet" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "milstolpe" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "färdig datum" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Gillar" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Gillar" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "slugg" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "Beräknad startdatum" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "Beräknad slutdato" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "är stängd" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "disponerar" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "Beräknad startdatum måste vara tidigare än beräknad slutdatum. " + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "är blockerad" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' parameter är obligatoriskt" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' parameter är obligatoriskt" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "e-post" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "skapa som" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "textsträng" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "Invitation - extra text" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "användarorder" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "Användaren är redan medlem i projekt" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "standard US-poäng" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "standardpoäng" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "standard status för uppgift" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "standard prioritet" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "standard allvarsgrad" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "standard status för ärende" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "standard typ för ärende" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "medlemmar" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "totalt antal milstolpar" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "totalt antal historiepoäng" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "aktivt panel för inkorg" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "aktiv kanban-panel" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "aktiv wiki-panel" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "aktiv panel för ärenden" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "videokonferensssystem" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "videokonferens - extra data" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "mall skapas" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "är privat" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "anonyma rättigheter" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "användarbehörigheter" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "uppdaterad dato och tid" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "räkna" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "konfigurera moduler" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "är arkiverad" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "begränsad arbete pågår" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "värde" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "ägarens standardroll" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "standard val" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "US statuser" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "poäng" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "statuser för uppgifter" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "status för ärenden" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "ärendentyper" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "prioriteter" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "allvarsgrad" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "roller" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Involverad" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Alla" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Ingen" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "skapad dato och tid" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "historienotat" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "notifiera användare" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "Visad" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Notifiering finns för användaren och projektet" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Felaktigt värde för notifieringen" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "Listan på bevakare består av felaktiga användare" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "Versionen måste vara ett heltal" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "Versionsparametern är ogiltig" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Versionen stämmer inte med den aktuella versionen" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "version" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The project doesn't exist" +msgid "User does not exist." +msgstr "Projektet existerar inte" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Denna användare är redan medlem i projektet." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "ny e-postadress" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Framtidig sprint" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Projektslut" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Textsträngen är ogiltig" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "taggar" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "färger för taggar" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "Du har inte behörighet åt att sätta sprinten till en uppgift" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "Du har inte behörighet att sätta använderhistorien till en uppgift." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "Du har inte behörighet att sätta status till en uppgift. " + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "sortera US" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "Sortera uppgiftstavlan" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "är Iocaine" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "någon" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Accceptera din invitation till Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Acceptera din invitation" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Acceptera din invitation till Taiga via den här länken:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Den smidiga inkorgen i Scrum är en lista med prioriterade områden, som " +"innehåller korta beskrivningar av alla funktioner som önskas i produkten. " +"Vid tillämpning av Scrum, är det inte nödvändigt att starta ett projekt med " +"en lång lista dokumentationskrav. Scrum inkorgen får växa och förändras när " +"man lär sig om produkten, funktioner och kunder. " + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban är en metod för att hantera kunskapsarbete med betoning på just-in-" +"time-leveranser utan överbelastning av gruppmedlemmarna. Det här " +"tillvägagångssättet och processen, från definitionen av ett uppdrag till " +"kundleverans, visas för deltagarna att se och teammedlemmar drar arbete från " +"uppdragskön." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Ny" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Leveransklar" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "Pågående" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Klart till test" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Färdig" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Arkiverad" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Stängd" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Behöver information" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Uppskjutit" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Avslått" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Bugg" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Fråga" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "Förbättring" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Låg" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normal" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Hög" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "Önskelista" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Mindre" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Viktig" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Kritiskt" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Design" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Framsida" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Baksida" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Produktägare" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Intressent" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" +"Du har inte behörighet för att lägga sprinten till den här användarhistorien" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" +"Du har inte behörighet till att sätta den här statusen till " +"användarhistorien." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Skapar användarhistorie #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "roll" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "sortera inkorgen" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "sortera sprintar" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "färdig datum" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "skapad från ärende" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Det är inga användarhistoria med det ID-numret" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Det är inga projekt med det ID-numret" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Fel roll for projektet" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Standardval" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Status för användarhistorien" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Poäng" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Status för uppgifter" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Status för ärenden" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Ärendetyper" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Prioritet" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Allvarsgrad" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Roller" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Röster" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Rösta" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content' parametern är obligatoriskt" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id' parametern är obligatoriskt" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "senastste ändring" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Kolla historie API för exakt skillnad" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "medlemmar" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "Projektslut" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "Projektslut" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "E-post-dublett" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Ogiltigt användarnamn eller e-postadress" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Parameter för nuvarande lösenord krävs" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Parameter för nytt lösenord krävs" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Fel lösenord" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Fel. Är du säker på att strängen är korrekt och att du inte har använt det " +"tidigare?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Fel, är du säker på att textsträngen är korrekt? " + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "E-posten skickades korrekt" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "status för administratorn" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Anger om användaren har alla behörigheter utan att uttryckligen tilldela " +"dem. " + +#: taiga/users/models.py:120 +msgid "username" +msgstr "användarnamn" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" +"Obligatoriskt. 30 eller färre alfanumeriska tecken, bokstäver och /./-/_ . " + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Skriv in ett giltigt användarnamn" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "aktiv" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Anger om användaren ska betraktas som aktiv. Avmarkera detta i stället för " +"att ta bort kontot." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biografi" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "foto" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "blev medlem datum" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "blev medlem datum" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "standardspråk" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "standardtema" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "standard tidzon" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "farglägg taggar" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "e-poststräng" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "ny e-postadress" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "behörigheter" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Användarnamn eller lösenord passar inte." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "Ändra din e-postadress" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Återställa lösenord" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Du har blivit Taiganiserad!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "felaktigt" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Felaktigt användarnamn. Försök med ett annat användarnamn." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "Dublett-nyckelvärden bryter unik begränsning. Key \"{}\" finns redan." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "nyckel" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "Länk" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "hemlig nyckel" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "statuskod" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "begär data" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "begär titel" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "responsdata" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "responstitel" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "varaktighet" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Personal info" +#~ msgstr "Personalinformation" + +#~ msgid "Permissions" +#~ msgstr "Behörigheter" + +#~ msgid "Important dates" +#~ msgstr "Viktiga datum" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/tr/LC_MESSAGES/django.po b/taiga/locale/tr/LC_MESSAGES/django.po new file mode 100644 index 000000000..3a30e6f2c --- /dev/null +++ b/taiga/locale/tr/LC_MESSAGES/django.po @@ -0,0 +1,5705 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# abc Def , 2020 +# Can Aydin , 2015 +# catborise , 2015-2016 +# Mert Torun , 2016 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2023-03-10 13:37+0000\n" +"Last-Translator: Oğuz Ersen \n" +"Language-Team: Turkish \n" +"Language: tr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.16.2-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "geçersiz oturum açma türü" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "Herkese açık kayıt devre dışı bırakıldı." + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "Hizmet şartlarımızı ve gizlilik politikamızı kabul etmelisiniz" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "geçersiz kayıt türü" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "Yetkilendirme başlığı, boşlukla ayrılmış iki değer içermelidir" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "Verilen kupon, herhangi bir kupon türü için geçerli değil" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "Kupon, tanınabilir bir kullanıcı kimliği içermiyor" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "Kullanıcı bulunamadı" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "Kullanıcı etkin değil" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "Bilinmeyen algoritma türü '{}'" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "{} kullanmak için kriptografi kurmuş olmanız gerekir." + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "Geçersiz algoritma belirtildi" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "Kupon geçersiz veya süresi doldu" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "geçersiz kullanıcı adı" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" +"Zorunlu. 255 karakter ya da daha azı. Harfler, sayılar ve /./-/_ karakterleri" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "Geçersiz tam ad" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "Verilen kimlik bilgilerine sahip etkin bir hesap bulunamadı" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Kullanıcı adı zaten kullanımda." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "E-posta zaten kullanımda." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Kullanıcı zaten kayıtlı." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "Yeni kullanıcı oluşturulurken hata oluştu." + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "jti" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "kullanıcı" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "oluşturulma zamanı" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "sona eriyor" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "Kupon Reddetme Listesi" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "Türü veya kullanım ömrü olmayan kupon oluşturulamıyor" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "Kuponun kimliği yok" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "Kuponun türü yok" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "Kuponun türü yanlış" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "Kuponun '{}' talebi yok" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "Kupon '{}' talebinin süresi doldu" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "Kupon reddetme listesine alındı" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Bu alan zorunlu." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Geçersiz değer." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "%s' değeri ya Doğru ya da Yanlış olmalıdır." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Harfler, rakamlar, altçizgi ve kesme işaretinden oluşan geçerli bir 'satır' " +"girin." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"Geçerli bir seçenek belirleyin. %(value)s değeri mevcut seçenekler arasında " +"yok." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "E-posta etki alanı adınıza izin verilmiyor" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Geçerli bir e-posta adresi girin." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "Tarih biçemi yanlış. Belirtilen biçemlerden birini kullanın: %s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "Tarih saat biçemi yanlış. Belirtilen biçemlerden birini kullanın: %s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "Zaman biçemi yanlış. Belirtilen biçemlerden birini kullanın: %s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Bir tam sayı girin." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" +"Bu değerin %(limit_value)s değerine eşit ya da daha az olduğundan emin olun." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" +"Bu değerin %(limit_value)s değerine eşit ya da daha fazla olduğundan emin " +"olun." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" değeri kesirli bir sayı olmalıdır." + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Bir sayın girin." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Toplamda %s basamaktan fazla olmadığından emin olun." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "%s ondalık değerinden fazla olmalıdığından emin olun." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" +"Virgülden önceki rakamların %s basamaktan fazla olmadığından emin olun." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Dosya gönderilmedi. Formdaki kodlama türünü gözden geçirin." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Dosya gönderilmedi." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Gönderilen dosya boş." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Bu dosya adının en fazla %(max)d karakterden oluştuğundan (uzunluğunun " +"%(length)d olduğundan) emin olun." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" +"Lütfen ya bir dosya gönderin ya da onay kutusunu işaretleyin, her ikisini " +"birden yapmayın." + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Geçerli bir resim yükleyin. Yüklenen dosya ya bozulmuş bir resim ya da bir " +"resim dosyası değil." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Engellenmiş nesne" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "Sayfa 'last'(son) değil, tamsayıya da çevrilemiyor." + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Geçersiz sayfa (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Geçersiz izin tanımı." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Geçersiz pk '%s' - nesne mevcut değil." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "Yanlış tür. pk değeri beklendi, %s alındı." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "%s=%s objesi mevcut değil." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "Geçersiz hiperlink - URL eşleşmesi yok" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "Geçersiz hiperlink - Doğru olmayan URL eşleşmesi" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "Yapılandırma hatasından dolayı geçersiz hiperlink" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "Geçersiz hiperlink - nesne mevcut değil." + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "Yanlış tür. url dizgesi beklendi, %s alındı." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Geçersiz veri" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Girdi sağlanmadı" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "Yeni bir madde oluşturlamıyor, sadece var olanlar güncellenebilir." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Bir madde listesi bekleniyor." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Bulunamadı" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "İzin verilmedi" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Sunucu uygulaması hatası" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Bağlantı hatası." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "Yanlış biçimlendirilmiş istek." + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Hatalı oturum açma bilgileri." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Oturum açma bilgileri girilmedi." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "Bu eylemi gerçekleştirebilmek için gerekli izne sahip değilsiniz." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "'%s' yöntemine izin verilmiyor." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "İsteğin Kabul Et başlığı karşılanamadı" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "İstekte desteklenmeyen ortam türü '%s' var." + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "İstek kısıtlandı." + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "%d saniye(%s) içinde kullanılabilir olması bekleniyor." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Belirlenmeyen hata" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Bulunamadı." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "Bu uç nokta için yöntem desteklenmiyor." + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Hatalı parametreler." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Veri doğrulama hatası" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "Yanlış veya geçersiz parametreler için Bütünlük Hatası" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "Ön şart hatası" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Daha fazla proje için yer kalmadı." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s bir liste değil" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "Parametre türleri filtresinde hata." + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project' değeri numerik olmalı." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "Taigalandınız" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

Açık Kaynaklı, Çevik " +"Proje Yönetimi Aracı Olan %(product_name)s'ya Hoş Geldiniz

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" E-posta bildirimlerini " +"yapılandırın veya abonelikten çıkın\n" +"  • \n" +" Taiga Destek\n" +"  • \n" +" Bizimle iletişime " +"geçin\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Twitter da bizi takip edin" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "GitHub dan kodu elde edin" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Güncellemeler" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Güncellemeler" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

yorum:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Yorum: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Ana makine erişim hatası" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "IP adresi hatası" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "Kullanıcı hikayesi oluşturuldu" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "Kullanıcı hikayesi değiştirildi" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "Kullanıcı hikayesi silindi" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "Kullanıcı hikayesi #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Görev oluşturuldu" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Görev değiştirildi" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Görev silindi" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Görev #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Talep oluşturuldu" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Talep değiştirildi" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Talep silindi" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Talep: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Wiki sayfası oluşturuldu" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Wiki sayfası değiştirildi" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Wiki sayfası silindi" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Wiki sayfası: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Koşu oluşturuldu" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Koşu değiştirildi" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Koşu silindi" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Koşu: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "En azından bir role ihtiyacımız var" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "İhtiyaç duyulan döküm dosyası" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "Geçersiz döküm biçemi" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "proje verileri içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "roller içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "üyelikler içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "proje öznitelikleri listesi içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "öntanımlı proje öznitelik değerleri içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "özel öznitelikler içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "koşular içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "talepler içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "kullanıcı hikayeleri içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "destanlar içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "görevler içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "wiki sayfaları içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "wiki bağlantıları içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "etiketler içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "zaman çizelgeleri içe aktarılırken hata oluştu" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "proje içe aktarılırken beklenmeyen bir hata oluştu" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "Daha fazla gizli projeye sahip olamazsınız" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "Bu proje, gizli projeler için geçerli üyelik sınırınıza ulaştı" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "Daha fazla herkese açık projeye sahip olamazsınız" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "Bu proje, herkese açık projeler için geçerli üyelik sınırınıza ulaştı" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "Proje dökümü oluşturulurken hata oluştu" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"{user_full_name} <{user_email}> tarafından oluşturulan döküm yüklenirken " +"hata oluştu:\"\n" +"\n" +"\n" +"NEDEN:\n" +"-------\n" +"{reason}\n" +"\n" +"AYRINTILAR:\n" +"--------\n" +"{details}\n" +"\n" +"HATA İZLEMESİ:\n" +"------------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "Proje dökümü yükleniyorken hata" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "Proje döküm dosyanız yüklenirken hata oluştu" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr " -- ayrıntı bilgisi yok --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Proje dökümü oluşturuldu

\n" +"

Merhaba %(user)s,

\n" +"

%(project)s projesindeki dökümünüz doğru şekilde oluşturuldu.

\n" +"

Buradan indirebilirsiniz:

\n" +" Döküm dosyasını indir\n" +"

Bu dosya %(deletion_date)s tarihinde silinecek.

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(user)s,\n" +"\n" +"%(project)s projesindeki dökümünüz doğru şekilde oluşturuldu. Buradan " +"indirebilirsiniz:\n" +"\n" +"%(url)s\n" +"\n" +"Bu dosya %(deletion_date)s tarihinde silinecek.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] Projenizin dökümü oluşturuldu" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Merhaba %(user)s,

\n" +"

%(project)s projeniz doğru şekilde dışa aktarılmadı.

\n" +"

Taiga sistem yöneticilerine bilgi verildi.
Lütfen tekrar deneyin " +"veya aşağıdaki adresten destek ekibiyle iletişime geçin:\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(user)s,\n" +"\n" +"%(error_message)s\n" +"%(project)s projeniz doğru şekilde dışa aktarılmadı.\n" +"\n" +"Taiga sistem yöneticilerine bilgi verildi.\n" +"\n" +"Lütfen tekrar deneyin veya %(support_email)s adresinden destek ekibiyle " +"iletişime geçin.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Merhaba %(user)s,

\n" +"

Projeniz doğru şekilde içe aktarılmadı.

\n" +"

%(product_name)s sistem yöneticilerine bilgi verildi.
Lütfen " +"tekrar deneyin veya aşağıdaki adresten destek ekibiyle iletişime geçin:\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Hata ayrıntıları

\n" +"
%(details)s
\n" +" " + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Projeniz doğru şekilde içe aktarılmadı.\n" +"\n" +"%(product_name)s sistem yöneticilerine bilgi verildi.\n" +"\n" +"Lütfen tekrar deneyin veya %(support_email)s adresinden destek ekibiyle " +"iletişime geçin.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Proje dökümü içe aktarıldı

\n" +"

Merhaba %(user)s,

\n" +"

Proje dökümünüz doğru şekilde içe aktarıldı.

\n" +" %(project)s'e git\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(user)s,\n" +"\n" +"Proje dökümünüz doğru şekilde içe aktarıldı\n" +"\n" +"%(project)s projesini buradan görebilirsiniz:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] Projenizin döküm dosyası içe aktarıldı" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" bu projede bulunamadı" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Geçersiz içerik. {\"key\": \"value\",...} şeklinde olması zorunlu" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "Geçersiz özel alanlar içeriyor." + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "Yinelenen ad" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" +"Bir destanın harici bir projeden (%(project)s) ilgili bir hikayesi var ve " +"içe aktarılamıyor" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Kimlik doğrulama gerekli" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "isim" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "İkon url" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "web" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "tanı" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "Sonraki url" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "uygulama" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Geçersiz kupon" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "tam ad" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "e-posta adresi" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "yorum" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "oluşturma tarihi" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

Geri Bildirim

\n" +"

Taiga, %(full_name)s <%(email)s> kullanıcısından geri bildirim aldı\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

Yorum

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Ek bilgi" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- Kimden: %(full_name)s <%(email)s>\n" +"---------\n" +"- Yorum:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Ekstra bilgi:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s <%(email)s> kullanıcısından geri bildirim\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "Paket geçerli bir json değil" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Proje mevcut değil" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "Kötü imza" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" +"[{user_name}]({user_url} \"{user_name}'in {platform} profiline bak\") diyor " +"ki [{platform}#{number}]({comment_url} \"Yoruma git\"):\n" +"\n" +"\"{comment_message}\"" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"{platform} yorumu:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "Geçersiz talep yorum bilgisi" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Talep [{user_name}] tarafından({user_url} \"{user_name} kullanıcısının " +"{platform} profiline bakın\") [{platform}#{number}] üzerinden " +"oluşturuldu({url} \"Talebe git\")." + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "Talep {platform} üzerinden oluşturuldu." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Talep [{user_name}] tarafından({user_url} \"{user_name} kullanıcısının " +"{platform} profiline bakın\") [{platform}#{number}] üzerinden " +"değiştirildi({url} \"Talebe git\")." + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "Talep {platform} üzerinden değiştirildi." + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Talep [{user_name}] tarafından({user_url} \"{user_name} kullanıcısının " +"{platform} profiline bakın\") [{platform}#{number}] üzerinden " +"kapatıldı({url} \"Talebe git\")." + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "Talep {platform} üzerinden kapatıldı." + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" +"Talep [{user_name}] tarafından({user_url} \"{user_name} kullanıcısının " +"{platform} profiline bakın\") [{platform}#{number}] üzerinden yeniden " +"açıldı({url} \"Talebe git\")." + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "Talep {platform} üzerinden yeniden açıldı." + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "Geçersiz talep bilgisi" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "bilinmeyen kullanıcı" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} [{platform} işleminden] durumu değiştirdi ({commit_url} " +"\"'{commit_id} - {commit_short_message}' işlemine bakın\")\n" +"\n" +" - Durum: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{platform} işleminden durum değiştirildi.\n" +"\n" +" - Durum: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"Bu {type_name} hakkında, [{platform} işleminde] \"{commit_message}\" içinde " +"{user_text} tarafından bahsedildi ({commit_url} \"'{commit_id} - " +"{commit_short_message}' işlemine bakın\")" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" +"Bu talep hakkında, {platform} işleminde \"{commit_message}\" içinde " +"bahsedildi" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "Referans gösterilmiş varlık mevcut değil" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Durum mevcut değil" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Proje parametresi gerekli" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "Geçersiz Asana API isteği" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "Asana API'sine istekte bulunulamadı" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "Kod parametresi gerekli" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "Asana projesi içe aktarılırken hata oluştu" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "Geçersiz kimlik doğrulama verileri" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "Üçüncü taraf hizmeti başarısız" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "GitHub projesi içe aktarılırken hata oluştu" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "url parametresi gerekli" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" +"\n" +" Muhtemelen desteklenmeyen bir Jira sürümü nedeniyle bir hata " +"oluştu.\n" +" Taiga, 8.6'dan sonraki Jira sürümlerini desteklemez." + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "Geçersiz project_type {}" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "Geçersiz Jira sunucu yapılandırması." + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "Geçersiz veya süresi dolmuş kimlik doğrulama kuponu" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "Jira projesi içe aktarılırken hata oluştu" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "PivotalTracker projesi içe aktarılırken hata oluştu" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Asana Projesi içe aktarıldı

\n" +"

Merhaba %(user)s,

\n" +"

Asana projeniz doğru şekilde içe aktarıldı.

\n" +" %(project)s'e git\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(user)s,\n" +"\n" +"Asana projeniz doğru şekilde içe aktarıldı.\n" +"\n" +"%(project)s projesini buradan göebilirsiniz:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] Asana projeniz içe aktarıldı" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

GitHub Projesi içe aktarıldı

\n" +"

Merhaba %(user)s,

\n" +"

GitHub projeniz doğru şekilde içe aktarıldı.

\n" +" %(project)s'e git\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(user)s,\n" +"\n" +"GitHub projeniz doğru şekilde içe aktarıldı.\n" +"\n" +"%(project)s projesini buradan göebilirsiniz:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] GitHub projeniz içe aktarıldı" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

Merhaba %(user)s,

\n" +"

Projeniz doğru şekilde içe aktarılmadı.

\n" +"

%(product_name)s sistem yöneticilerine bilgi verildi.
Lütfen " +"tekrar deneyin veya aşağıdaki adresten destek ekibiyle iletişime geçin:\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Jira Projesi içe aktarıldı

\n" +"

Merhaba %(user)s,

\n" +"

Jira projeniz doğru şekilde içe aktarıldı.

\n" +" %(project)s'e git\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(user)s,\n" +"\n" +"Jira projeniz doğru şekilde içe aktarıldı.\n" +"\n" +"%(project)s projesini buradan göebilirsiniz:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s] Jira projeniz içe aktarıldı" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Trello Projesi içe aktarıldı

\n" +"

Merhaba %(user)s,

\n" +"

Trello projeniz doğru şekilde içe aktarıldı.

\n" +" %(project)s'e git\n" +"

%(signature)s

\n" +" " + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(user)s,\n" +"\n" +"Trello projeniz doğru şekilde içe aktarıldı.\n" +"\n" +"%(project)s projesini buradan göebilirsiniz:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] Trello projeniz içe aktarıldı" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "Geçersiz İstek: %(text)s, %(url)s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "Yetkisiz: %(text)s, %(url)s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "Kaynak Kullanılamıyor: %(text)s, %(url)s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "Trello projesi içe aktarılırken hata oluştu" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Projeyi görüntüle" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "Aşamaları görüntüle" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Destanları görüntüle" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Kullanıcı hikayelerini görüntüle" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Görevleri görüntüle" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Talepleri görüntüle" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Wiki sayfalarını görüntüle" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Wiki bağlantılarını görüntüle" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "Aşama ekle" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "Aşama düzenle" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "Aşama sil" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Destan ekle" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Destanı değiştir" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "Destana yorum ekle" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Destan sil" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Kullanıcı hikayesini görüntüle" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Kullanıcı hikayesi ekle" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Kullanıcı hikayesi düzenle" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "Kullanıcı hikayesine yorum ekle" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Kullanıcı hikayesi sil" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Görev ekle" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Görev düzenle" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Göreve yorum ekle" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Görev sil" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Talep ekle" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Talep düzenle" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "Talebe yorum ekle" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Talep sil" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "Wiki sayfası ekle" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "Wiki sayfası düzenle" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "Wiki sayfasına yorum ekle" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "Wiki sayfası sil" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "Wiki bağlantısı ekle" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "Wiki bağlantısı düzenle" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "Wiki bağlantısı sil" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Proje düzenle" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Proje sil" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Üye ekle" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Üye sil" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "Yönetici proje değerleri" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "Yönetici rolleri" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "Gizlilik" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "Modüller" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Öntanımlı değerler" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "Etkinlik" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "Hayranlar" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "sahip" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Herkese açık yap" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} tane başarıyla herkese açık yapıldı." + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Gizli hale getir" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} tane başarıyla gizli hale getirildi." + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Eksik parametreq" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Geçersiz resim biçemi" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "Geçersiz şablon adı" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "Geçersiz şablon açıklaması" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "Geçersiz kullanıcı id" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "Kullanıcı mevcut değil" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "Kullanıcı halihazırda bir proje üyesi olmalıdır" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "Öntanımlı kulvar silinemiyor." + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "Bir kullanıcı hikayesinin öntanımlı bitim tarihi durumunu silemezsiniz" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "Projenin zaten bitim tarihleri var" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "Bir görevin öntanımlı bitim tarihi durumunu silemezsiniz" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "Projenin zaten görev bitim tarihleri var" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "Bir talebin öntanımlı bitim tarihi durumunu silemezsiniz" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "Projenin zaten talep bitim tarihleri var" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" +"Bir projeye üye eklemek için önce e-posta adresinizi doğrulamanız gerekir" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" +"Bu kullanıcı, herhangi bir etkin yönetici kalmayacağı için şu projelerden " +"çıkarılamıyor: {}." + +#: taiga/projects/api.py:1125 +msgid "Email is required" +msgstr "E-posta gerekli" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" +"Projenin bir sahibi olmalı ve kullanıcılardan en az biri etkin bir yönetici " +"olmalıdır" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "Bunu görme izniniz yok." + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "Kısmi güncellemeler desteklenmiyor" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "Nesne kimliği talebi yok" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "Proje kimliği, nesne ve proje arasında eşleşmiyor" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "proje" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "içerik türü" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "nesne kimliği" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "düzenleme tarihi" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "eklenmiş dosya" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "kaldırıldı" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "yorumdan" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "sıra" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" +"Sonrasına taşınacak geçersiz ek kimliği. Ek aynı ögeye (destan, kullanıcı " +"hikayesi, görev, talep veya wiki sayfası) ait olmalıdır." + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" +"Geçersiz ek kimlikleri. Tüm ekler aynı ögeye (destan, kullanıcı hikayesi, " +"görev, talep veya wiki sayfası) ait olmalıdır." + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "Whereby.com" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "Özel" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "Bu proje, ödeme hatası nedeniyle engellendi" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "Bu proje, yönetici personel tarafından engellendi" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "Yetkili kalmadığı için proje bloklandı" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "Bu proje, silinirken engellendi" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +" %(full_name)s, " +"%(project_name)s projesine yazdı\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +" %(project_name)s adlı projenin yöneticisi olarak listelendiğiniz " +"için bu mesajı alıyorsunuz. Taiga topluluğu üyelerinin projenizle iletişim " +"kurmasını istemiyorsanız, lütfen bunu önlemek için proje ayarlarınızı güncelleyin. Proje " +"üyeleri arasındaki normal iletişim bundan etkilenmeyecektir.\n" +" " + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s, %(project_name)s projesine yazdı\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +"%(project_name)s adlı projenin yöneticisi olarak listelendiğiniz için bu " +"mesajı alıyorsunuz. Taiga topluluğu üyelerinin projenizle iletişim kurmasını " +"istemiyorsanız, lütfen bunu önlemek için %(project_settings_url)s sayfasında " +"proje ayarlarınızı güncelleyin. Proje üyeleri arasındaki normal iletişim " +"bundan etkilenmeyecektir.\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s, %(project_name)s projesine bir mesaj gönderdi\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "Metin" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "Çoklu-satır metin" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "Zengin metin" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "Tarih" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "Url" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "Açılır menü" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "Onay kutusu" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "Sayı" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "tür" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "değerler" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "destan" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "kullanıcı hikayesi" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "görev" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "talep" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "Aynı isimler bir tane daha mevcut." + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "bitim tarihi" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "bitim tarihi nedeni" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "Bu destan için bu durumu ayarlama izniniz yok." + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "durum" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "destanların sırası" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "konu" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "renk" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "atanmış" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "müşteri gereksinimi" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "takım gereksinimi" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "kullanıcı hikayeleri" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "dış referans" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "Bu kimliğe sahip bir destan yok" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "yorum gerekli" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "silinen yorumlar düzenlenemez" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "Yorum zaten silinmiş" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "Yorum silinmedi" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "Değiştir" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "Oluştur" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "Sil" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s role puanları" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "den" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "kime" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "Yeni ek ekle" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "Güncellenmiş ek" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "kaldırıldı" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "kaldırılmadı" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Silinmiş ek" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "eklendi" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "silindi" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Atanmamış" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "Ayarlanmadı" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-silinmiş-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "kime:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "kimden:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Eklenmiş" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Değiştirilmiş" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Silinmiş" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "eklenmiş:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "silinmiş:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Kimden:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Kime:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "içerik" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "engellenmiş not" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "koşu" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "Bu talep için bu koşuyu ayarlamaya yetkiniz yok." + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "Bu talep için bu durumu ayarlamaya yetkiniz yok." + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "Bu talep için bu kritiklik derecesini ayarlamaya yetkiniz yok." + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "Bu talep için bu öncelik durumunu ayarlamaya yetkiniz yok." + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "Bu talep için bu türü ayarlamaya yetkiniz yok." + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "önem derecesi" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "öncelik" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "aşama" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "bitirme tarihi" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "Aşama, proje için geçerli değil" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "Tüm talepler aynı projeden olmalıdır" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "Beğen" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "Beğeniler" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "satır" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "yaklaşık başlama tarihi" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "yaklaşık bitiş tarihi" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "kapatılmış" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "taşınabilirlik" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "Tahmini başlangıç, tahmini bitişten önce olmalı." + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "Bu kimliğe sahip bir aşama yok" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "Tüm kullanıcı hikayeleri aynı projeden olmalıdır" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "engellenmiş" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "referans parametresi gerekli" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "proje veya project__slug parametresi gerekli" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "'moveTo' sorgu parametresi gerekli" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "Kullanılabilir kulvarlar varsa kulvar \"Yok\" olarak ayarlanamıyor" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' parametresi zorunlu" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'proje' parametresi zorunlu" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "Kullanıcı bir proje üyesi olmalıdır." + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "e-posta" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "oluşturulma zamanı" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "kupon" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "Davetiye ekstra metni" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "kullanıcı sırası" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "Kullanıcı zaten projenin üyesi" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "öntanımlı destan durumu" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "öntanımlı KH durumu" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "öntanımlı puanlar" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "öntanımlı görev durumu" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "öntanımlı öncelik" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "öntanımlı önem derecesi" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "öntanımlı talep durumu" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "öntanımlı talep türü" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "öntanımlı kulvar" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "logo" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "üyeler" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "aşamaların toplamı" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "toplam hikaye puanı" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "etkin kişi" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "etkin destanlar paneli" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "etkin havuz paneli" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "etkin kanban paneli" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "etkin wiki paneli" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "etkin talep paneli" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "video konferans sistemi" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "videokonferans ekstra verisi" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "oluşturma şablonu" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "gizli" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "anonim izinler" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "kullanıcı izinleri" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "vitrinde" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "insan arıyor" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "insan arıyor notu" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "proje aktarım kuponu" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "engellenmiş kod" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "yükleme tarih-saati" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "sayı" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "geçen haftanın hayranları" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "geçen ayın hayranları" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "geçen yılın hayranları" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "geçen haftanın etkinlikleri" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "geçen ayın etkinlikleri" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "geçen yılın etkinlikleri" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "modül ayarları" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "arşivlenmiş" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "work in progress (devam eden çalışma) sınırı" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "değer" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "öntanımlı olarak" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "bitime kalan gün" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "kullanıcı hikayesi durumu" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "öntanımlı sahip rolü" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "öntanımlı seçenekler" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "destan durumları" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "kh durumları" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "kh bitim tarihleri" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "puanlar" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "görev durumları" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "görev bitim tarihleri" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "talep durumları" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "talep türleri" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "talep bitim tarihleri" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "öncelikler" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "önem durumları" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "roller" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "destan özel öznitelikleri" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "kh özel öznitelikleri" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "görev özel öznitelikleri" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "talep özel öznitelikleri" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "Müdahil" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "Hepsi" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "Hiçbiri" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "Atandı" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "Bahsedildi" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "İzleyici olarak eklendi" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "Üye olarak eklendi" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "Yorum" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "Yorumda bahsedildi" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "oluşturma tarih-saati" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "tarihçe girdileri" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "kullanıcıları bilgilendir" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "İzlenen" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "Belirtilen kullanıcı ve proje için bilgilendirme mevcut" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "Bildirim düzeyi için geçersiz değer" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" +"\n" +"

Destan güncellendi

\n" +"

Merhaba %(user)s,
%(changer)s %(project)s üzerinde bir destanı " +"güncelledi

\n" +"

Destan #%(ref)s %(subject)s

\n" +" Destanı gör\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"Destan güncellendi\n" +"Merhaba %(user)s, %(changer)s %(project)s üzerinde bir destanı güncelledi\n" +"#%(ref)s %(subject)s destanını %(url)s adresinde görün\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" destanı güncellendi\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Yeni destan oluşturuldu

\n" +"

Merhaba %(user)s,
%(changer)s %(project)s üzerinde yeni bir " +"destan oluşturdu

\n" +"

Desyan #%(ref)s %(subject)s

\n" +" Destanı gör\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Yeni destan oluşturuldu\n" +"Merhaba %(user)s, %(changer)s %(project)s üzerinde yeni bir destan " +"oluşturdu\n" +"#%(ref)s %(subject)s destanını %(url)s adresinde görün\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" destanı oluşturuldu\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Destan silindi

\n" +"

Merhaba %(user)s,
%(changer)s %(project)s üzerinde bir destanı " +"sildi

\n" +"

Destan #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Destan silindi\n" +"Merhaba %(user)s, %(changer)s %(project)s üzerinde bir destanı sildi\n" +"Destan #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" destanı silindi\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde talep güncellendi

\n" +"

Merhaba %(user)s,
%(changer)s bir talebi güncelledi:

\n" +"

#%(ref)s %(subject)s

\n" +" Talebi gör\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" +"\n" +"%(project)s üzerinde talep güncellendi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir talebi güncelledi:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"%(url)s adresinde talebi görün\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" talebi güncellendi\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde yeni talep oluşturuldu

\n" +"

Merhaba %(user)s,
%(changer)s yeni bir talep oluşturdu:

\n" +"

#%(ref)s %(subject)s

\n" +" Talebi gör\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde yeni talep oluşturuldu\n" +"\n" +"Merhaba %(user)s, %(changer)s yeni bir talep oluşturdu:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"%(url)s adresinde talebi görün\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" talebi oluşturuldu\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde talep silindi

\n" +"

Merhaba %(user)s,
%(changer)s bir talebi sildi:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde talep silindi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir talebi sildi:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" talebi silindi\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde koşu güncellendi

\n" +"

Merhaba %(user)s,
%(changer)s bir koşuyu güncelledi:

\n" +"

%(name)s

\n" +" Koşuyu gör\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" +"\n" +"%(project)s üzerinde koşu güncellendi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir koşuyu güncelledi:\n" +"\n" +"%(name)s\n" +"\n" +"%(url)s adresinde koşuyu görün\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(milestone)s\" koşusu güncellendi\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde yeni koşu oluşturuldu

\n" +"

Merhaba %(user)s,
%(changer)s yeni bir koşu oluşturdu

\n" +"

%(name)s

\n" +" Koşuyu gör\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde yeni koşu oluşturuldu\n" +"\n" +"Merhaba %(user)s, %(changer)s yeni bir koşu oluşturdu:\n" +"\n" +"%(name)s\n" +"\n" +"%(url)s adresinde koşuyu görün\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(milestone)s\" koşusu oluşturuldu\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde koşu silindi

\n" +"

Merhaba %(user)s,
%(changer)s bir koşuyu sildi:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde koşu silindi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir koşuyu sildi:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(milestone)s\" koşusu silindi\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde görev güncellendi

\n" +"

Merhaba %(user)s,
%(changer)s bir görevi güncelledi:

\n" +"

%(ref)s %(subject)s

\n" +" Görevi gör\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" +"\n" +"%(project)s üzerinde görev güncellendi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir görevi güncelledi:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"%(url)s adresinde görevi görün\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" görevi güncellendi\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde yeni görev oluşturuldu

\n" +"

Merhaba %(user)s,
%(changer)s yeni bir görev oluşturdu:

\n" +"

#%(ref)s %(subject)s

\n" +" Görevi gör\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde yeni görev oluşturuldu\n" +"\n" +"Merhaba %(user)s, %(changer)s yeni bir görev oluşturdu:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"%(url)s adresinde görevi görün\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" görevi oluşturuldu\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde görev silindi

\n" +"

Merhaba %(user)s,
%(changer)s bir görevi sildi:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde görev silindi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir görevi sildi:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] #%(ref)s \"%(subject)s\" görevi silindi\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde kullanıcı hikayesi güncellendi

\n" +"

Merhaba %(user)s,
%(changer)s bir kullanıcı hikayesini " +"güncelledi:

\n" +"

#%(ref)s %(subject)s

\n" +" Kullanıcı hikayesini gör\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" +"\n" +"%(project)s üzerinde kullanıcı hikayesi güncellendi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir kullanıcı hikayesini güncelledi:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"%(url)s adresinde kullanıcı hikayesini görün\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] KH #%(ref)s \"%(subject)s\" güncellendi\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde yeni kullanıcı hikayesi oluşturuldu

\n" +"

Merhaba %(user)s,
%(changer)s yeni bir kullanıcı hikayesi " +"oluşturdu:

\n" +"

#%(ref)s %(subject)s

\n" +" Kullanıcı hikayesini gör\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde yeni kullanıcı hikayesi oluşturuldu\n" +"\n" +"Merhaba %(user)s, %(changer)s yeni bir kullanıcı hikayesi oluşturdu:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"%(url)s adresinde kullanıcı hikayesini görün\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] KH #%(ref)s \"%(subject)s\" oluşturuldu\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde kullanıcı hikayesi silindi

\n" +"

Merhaba %(user)s,
%(changer)s bir kullanıcı hikayesini sildi:\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde kullanıcı hikayesi silindi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir kullanıcı hikayesini sildi:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] KH #%(ref)s \"%(subject)s\" silindi\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde wiki sayfası güncellendi

\n" +"

Merhaba %(user)s,
%(changer)s bir wiki sayfasını güncelledi:\n" +"

%(page)s

\n" +" Wiki " +"sayfasını gör\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" +"\n" +"%(project)s üzerinde wiki sayfası güncellendi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir wiki sayfasını güncelledi:\n" +"\n" +"%(page)s\n" +"\n" +"%(url)s adresinde wiki sayfasını görün\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(page)s\" wiki sayfası güncellendi\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde yeni wiki sayfası oluşturuldu

\n" +"

Merhaba %(user)s,
%(changer)s yeni bir wiki sayfası oluşturdu\n" +"

%(page)s

\n" +" Wiki " +"sayfasını gör\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde yeni wiki sayfası oluşturuldu\n" +"\n" +"Merhaba %(user)s, %(changer)s yeni bir wiki sayfası oluşturdu:\n" +"\n" +"%(page)s\n" +"\n" +"%(url)s adresinde wiki sayfasını görün\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(page)s\" wiki sayfası oluşturuldu\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(project)s üzerinde wiki sayfası silindi

\n" +"

Merhaba %(user)s,
%(changer)s bir wiki sayfasını sildi:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"%(project)s üzerinde wiki sayfası silindi\n" +"\n" +"Merhaba %(user)s, %(changer)s bir wiki sayfasını sildi:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] \"%(page)s\" wiki sayfası silindi\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "İzleyiciler arasında geçersiz kullanıcılar var" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "Sürüm rakamsal bir şey olmalıdır" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "Sürüm parametresi geçersiz" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "Sürüm geçerli olanla uyuşmuyor" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "sürüm" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "Sahibiyseniz veya başka yönetici yoksa projeden ayrılamazsınız" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "Kupon, herhangi bir geçerli davetle eşleşmiyor." + +#: taiga/projects/services/invitations.py:74 +msgid "User does not exist." +msgstr "Kullanıcı yok." + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Bu kullanızı halihazırda zaten projenin bir üyesi." + +#: taiga/projects/services/members.py:46 +msgid "Malformed email adress." +msgstr "Hatalı biçimlendirilmiş e-posta adresi." + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "Sahibi olmayan proje" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "Gizli projeler için geçerli üyelik sınırınıza ulaştınız" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "Herkese açık projeler için geçerli üyelik sınırınıza ulaştınız" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "Geçerli bekleyen üyelik sınırına ulaştınız" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "Görev #%(ref)s" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "Gelecek koşu" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "Proje Sonu" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "Kupon geçersiz" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "Kuponun süresi doldu" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "Zaman Çizelgesi" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "Destanlar" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "Havuz" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "Sorunlar" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "TakımWikisi" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "Bu bölüme erişiminiz yok" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" +"Geçersiz etiket '{value}'. Renk, geçerli bir HEX rengi veya boş (null) değil." + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"Geçersiz etiket '{value}'. Ad veya bir '[\"ad\", \"hex rengi/\" | null]' " +"çifti olmalıdır." + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "Geçersiz etiket '{value}'. Etiket adı olmalıdır." + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "etiketler" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "etiket renkleri" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Bu etiket zaten var." + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "Renk, geçerli bir HEX rengi değil." + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "Etiket mevcut değil." + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "Bu görev için bu koşuyu ayarlama izniniz yok." + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "Bu görev için bu kullanıcı hikayesini ayarlama izniniz yok." + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "Bu görev için bu durumu ayarlama izniniz yok." + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "kh sırası" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "görev panosu sırası" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "baldıran zehri" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "Geçersiz aşama kimliği." + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "Geçersiz görev durumu kimliği." + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "Geçersiz kullanıcı hikayesi kimliği." + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "Geçersiz görev durumu kimliği. Durum, aynı projeye ait olmalıdır." + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" +"Geçersiz kullanıcı hikayesi kimliği. Kullanıcı hikayesi, aynı projeye ait " +"olmalıdır." + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "Geçersiz aşama kimliği. Aşama, aynı projeye ait olmalıdır." + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" +"Geçersiz görev kimlikleri. Tüm görevler aynı projeye ve varsa aynı duruma, " +"kullanıcı hikayesine ve/veya aşamaya ait olmalıdır." + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "Tüm görevler aynı projeden olmalıdır" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "birisi" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" +"\n" +"

%(product_name)s için davet edildiniz!

\n" +"

Merhaba! %(full_name)s size %(product_name)s içindeki %(project)s projesine katılmanız için bir davet gönderdi.
Taiga bir Açık " +"Kaynaklı Çevik Proje Yönetimi Aracıdır. Taiga kullanıcısı olmanın hiçbir " +"maliyeti yoktur.

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

Ve sizi davet etmekle çok nazik davranan güzel ve iyi " +"arkadaş
diyor ki:

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Taiga davetinizi kabul edin" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Daveti kabul edin" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" +"\n" +"Siz veya tanıdığınız biri sizi %(product_name)s için davet etti\n" +"\n" +"Merhaba! %(full_name)s size %(product_name)s içinde %(project)s adlı bir " +"projeye katılmanız için bir davet gönderdi.\n" +"Taiga bir Açık Kaynaklı Çevik Proje Yönetimi Aracıdır. Taiga kullanıcısı " +"olmanın hiçbir maliyeti yoktur.\n" +"\n" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"Ve sizi davet etmekle çok nazik davranan güzel ve iyi arkadaş diyor ki:\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Aşağıdaki bağlantıyı takip ederek Taiga davetinizi kabul edin:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] '%(project)s' projesine katılma daveti\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Bir projeye eklendiniz

\n" +"

Merhaba %(full_name)s,
%(project)s projesine eklendiniz

\n" +" Projeye " +"git\n" +"

%(signature)s

\n" +" " + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bir projeye eklendiniz\n" +"\n" +"Merhaba %(full_name)s, %(project)s projesine eklendiniz\n" +"\n" +"%(url)s adresinde projeyi görün\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] '%(project)s' projesine eklendi\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Merhaba %(old_owner_name)s,

\n" +"

%(new_owner_name)s teklifinizi kabul etti ve \"%(project_name)s\" " +"projesinin yeni sahibi olacak.

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s diyor ki:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

Şu andan itibaren bu proje için yeni durumunuz \"yönetici\" " +"olacak.

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"Merhaba %(old_owner_name)s,\n" +"%(new_owner_name)s teklifinizi kabul etti ve \"%(project_name)s\" projesinin " +"yeni sahibi olacak.\n" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s diyor ki:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"Şu andan itibaren bu proje için yeni durumunuz \"yönetici\" olacak.\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] Proje sahipliği aktarımı teklifi kabul edildi!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Merhaba %(owner_name)s,

\n" +"

%(rejecter_name)s teklifinizi reddetti ve \"%(project_name)s\" " +"projesinin yeni sahibi olmayacak.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s diyor ki:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

İsterseniz proje sahipliğini farklı bir kişiye aktarmayı " +"deneyebilirsiniz.

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "Farklı bir kişiye aktarım isteğinde bulun" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" +"\n" +"Merhaba %(owner_name)s,\n" +"%(rejecter_name)s teklifinizi reddetti ve \"%(project_name)s\" projesinin " +"yeni sahibi olmayacak.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s diyor ki:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"İsterseniz proje sahipliğini farklı bir kişiye aktarmayı deneyebilirsiniz.\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "Farklı bir kişiye aktarım isteğinde bulun:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] Proje sahipliği aktarımı reddedildi\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" +"\n" +"

Merhaba %(owner_name)s,

\n" +"

%(requester_name)s, \"%(project_name)s\" projesinin sahibi olmayı " +"istedi.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

Yönetim panelinden proje aktarımını başlatmak istiyorsanız lütfen " +"\"Devam et\" düğmesine tıklayın.

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Devam et" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" +"\n" +"Merhaba %(owner_name)s,\n" +"%(requester_name)s, \"%(project_name)s\" projesinin sahibi olmayı istedi.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"Yönetim panelinden proje aktarımını başlatmak istiyorsanız lütfen proje " +"ayarlarınıza gidin.\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Proje ayarlarınıza gidin:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s] Proje sahipliği aktarımı isteği\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

Merhaba %(receiver_name)s,

\n" +"

%(owner_name)s, \"%(project_name)s\" için şu anki proje sahibi, " +"yeni proje sahibi olmanızı istiyor.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s diyor ki:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

Bu teklifi kabul etmek veya reddetmek için lütfen \"Devam et\" " +"düğmesine tıklayın.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"Merhaba %(receiver_name)s,\n" +"%(owner_name)s, \"%(project_name)s\" için şu anki proje sahibi, yeni proje " +"sahibi olmanızı istiyor.\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s diyor ki:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"Bu teklifi kabul etmek veya reddetmek için lütfen aşağıdaki bağlantıya gidin." +"

\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "Proje sahipliği aktarımını kabul et veya reddet:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s] Proje sahipliği aktarımı teklifi\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Scrum'daki çevik ürün havuzu, üründe istenen tüm işlevlerin kısa " +"açıklamalarını içeren, öncelikli bir özellikler listesidir. Scrum'ı " +"uygularken, bir projeye tüm gereksinimleri belgelendirmek için uzun ve önden " +"yapılan bir çalışmayla başlamak gerekli değildir. Ürün ve müşterileri " +"hakkında daha fazla şey öğrenildikçe Scrum ürün havuzunun büyümesine ve " +"değişmesine sonradan izin verilir" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban, takım üyelerini aşırı yüklemeden tam zamanında teslimata vurgu " +"yaparak bilgi çalışmasını yönetmek için bir yöntemdir. Bu yaklaşımda, bir " +"görevin tanımlanmasından müşteriye teslimine kadar olan süreç, " +"katılımcıların görmesi için görüntülenir ve ekip üyeleri çalışmayı bir " +"kuyruktan çeker." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Yeni" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Hazır" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "Devam ediyor" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Teste hazır" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Bitmiş" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Arşivlenmiş" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Kapatılmış" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "Bilgi İhtiyacı" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "Ertelenmiş" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "Reddedilmiş" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "Hata" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Soru" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "İyileştirme" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "Düşük" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "Normal" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "Yüksek" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "İstek Listesi" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "Küçük" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "Önemli" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "Kritik" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "UX" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "Tasarım" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "Ön" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "Arka" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "Ürün Sahibi" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "Paydaş" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "Bu kullanıcı hikayesine bu koşuyu ayarlama izniniz yok." + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "Bu kullanıcı hikayesine bu durumu ayarlama izniniz yok." + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "Bu kullanıcı hikayesine bu kulvarı ayarlama izniniz yok." + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "Geçersiz rol kimliği '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "Geçersiz puan kimliği '{points_id}'" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "Kullanıcı hikayesi oluşturuluyor #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "rol" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "havuz sırası" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "koşu sırası" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "kanban sırası" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "bitiş tarihi" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "atanan kullanıcılar" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "talepten oluşturulan" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "görevden oluşturulan" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "görevden referans" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "kulvar" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "Bu id ye sahip kullanıcı hikayesi yok" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" +"Geçersiz kullanıcı hikayesi durumu kimliği. Durum, aynı projeye ait " +"olmalıdır." + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "Geçersiz kulvar kimliği. Kulvar aynı projeye ait olmalıdır." + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" +"Sonrasına taşınacak geçersiz kullanıcı hikayesi kimliği. Kullanıcı hikayesi " +"aynı projeye ve aşamaya ait olmalıdır." + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "Sonrayı ve önceyi aynı anda kullanamazsınız." + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" +"Öncesine taşınacak geçersiz kullanıcı hikayesi kimliği. Kullanıcı hikayesi " +"aynı projeye ve aşamaya ait olmalıdır." + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" +"Geçersiz kullanıcı hikayesi kimlikleri. Tüm hikayeler aynı projeye ait " +"olmalıdır." + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"Sonrasına taşınacak geçersiz kullanıcı hikayesi kimliği. Kullanıcı hikayesi " +"aynı projeye, duruma ve kulvara ait olmalıdır." + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" +"Öncesine taşınacak geçersiz kullanıcı hikayesi kimliği. Kullanıcı hikayesi " +"aynı projeye, duruma ve kulvara ait olmalıdır." + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "Bu id ye sahip proje yok" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "Kullanıcı henüz projede var" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "Geçersiz işlem" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "Proje için geçersiz rol" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "Kullanıcı geçerli bir kişi olmalıdır" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "Proje sahibi yönetici olmalıdır." + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "Bu proje için en az bir kullanıcı etkin yönetici olmalıdır." + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "Geçersiz rol kimlikleri. Tüm roller aynı projeye ait olmalıdır." + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "Öntanımlı seçenekler" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "Kullanıcı hikayelerinin durumları" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "Puanlar" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "Görevlerin durumları" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "Taleplerin durumları" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "Taleplerin türleri" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "Öncelikler" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "Önem dereceleri" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "Roller" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Oylar" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Oy" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content' parametresi zorunlu" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id' parametresi zorunlu" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "son düzenleyen" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "örnek kimliği" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "Tam fark için geçmiş API'sine bakın" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "Proje Üyesi" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "Proje Üyeleri" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "kimlik" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "Proje Sahipliği" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "Proje Sahiplikleri" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "Üyelikler" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "KİŞİSEL BİLGİLER" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "EK BİLGİLER" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "İZİNLER" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "ÖNEMLİ TARİHLER" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "PROJE SAHİPLİĞİ KISITLAMALARI" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "PROJE SAHİPLİĞİ İSTATİSTİKLERİ" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "Sahip olunan özel projeler" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "Sahip olunan özel üyelikler" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "Sahip olunan herkese açık projeler" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "Sahip olunan herkese açık üyelikler" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Yinelenen e-posta" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "Geçersiz e-posta" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Geçersiz kullanıcı adı ya da e-posta" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "Posta başarıyla gönderildi!" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "Geçerli parola parametresi gerekli" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "Yeni parola parametresi gerekli" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "Geçersiz parola uzunluğu, en az 6 karakter gerekli" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Geçersiz şu anki parola" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Geçersiz geçerli bir kupona sahip olduğunuzdan ve bu kuponu daha önce " +"kullanmadığınızdan emin misiniz?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Geçersiz, kuponun doğru olduğuna emin misin?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "E-posta adresi zaten doğrulandı" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "Bu e-posta adresi doğrulanamıyor" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "Posta başarıyla gönderildi!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "superuser durumu" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "Bu kullanıcının, açıkça atamadan tüm izinlere sahip olduğunu belirtir." + +#: taiga/users/models.py:120 +msgid "username" +msgstr "kullanıcı adı" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" +"Zorunlu. 30 karakter ya da daha azı. Harfler, sayılar ve /./-/_ karakterleri" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Geçerli bir kullanıcı adı girin." + +#: taiga/users/models.py:127 +msgid "active" +msgstr "etkin" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" +"Bu kullanıcının etkin olarak kabul edilip edilmeyeceğini belirler. Hesapları " +"silmek yerine bunun seçimini kaldırın." + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "personel durumu" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "Kullanıcının bu yönetici sitesinde oturum açıp açamayacağını belirler." + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "biyografi" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "fotoğraf" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "katılma tarihi" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "iptal tarihi" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "şartlar kabul edildi" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "yeni şartlar okundu" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "öntanımlı dil" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "öntanımlı tema" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "öntanımlı saat dilimi" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "etiketleri renklendir" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "e-posta kuponu" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "yeni e-posta adresi" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "sahip olunan en fazla özel proje sayısı" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "sahip olunan en fazla herkese açık proje sayısı" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" +"sahip olunan tüm özel projeler için en fazla farklı kullanıcı üyelik sayısı" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" +"sahip olunan tüm herkese açık projeler için en fazla farklı kullanıcı üyelik " +"sayısı" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "izinler" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Kullanıcı adı veya parola kullanıcıyla eşleşmiyor." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

E-posta adresinizi değiştirin

\n" +"

Merhaba %(full_name)s,
lütfen e-posta adresinizi doğrulayın

\n" +" E-posta " +"adresini doğrula\n" +"

Siz istemediyseniz bu mesajı yok sayabilirsiniz.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(full_name)s, lütfen e-posta adresinizi doğrulayın\n" +"\n" +"%(url)s\n" +"\n" +"Siz istemediyseniz bu mesajı yok sayabilirsiniz.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] E-posta adresini değiştir" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

Parolanızı kurtarın

\n" +"

Merhaba %(full_name)s,
parolanızı kurtarmak istediniz

\n" +" Parolanızı kurtarın\n" +"

Siz istemediyseniz bu mesajı yok sayabilirsiniz.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(full_name)s, parolanızı kurtarmak istediniz\n" +"\n" +"%(url)s\n" +"\n" +"Siz istemediyseniz bu mesajı yok sayabilirsiniz.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Parola kurtar" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" +"\n" +"

Lütfen e-posta adresinizi doğrulayın

\n" +" E-" +"posta adresini doğrula\n" +" " + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" +"\n" +"

%(product_name)s'ya kaydolduğunuz için teşekkür ederiz

\n" +"

Çalışma yöntemlerinde devrim yaratan, büyüyen profesyonel " +"topluluğumuza katıldığınız için çok mutluyuz ve beğeneceğinizi umuyoruz.\n" +"

Bir sorunuz mu var? Yardıma ihtiyacınız olursa " +"%(support_email)s adresinden bize ulaşın.

\n" +"

%(signature)s

\n" +" \n" +" " + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" Buraya tıklayarak hesabınızı bu hizmetten " +"kaldırabilirsiniz\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" +"\n" +"%(product_name)s'ya kaydolduğunuz için teşekkür ederiz\n" +"\n" +"Çalışma yöntemlerinde devrim yaratan, büyüyen profesyonel topluluğumuza " +"katıldığınız için çok heyecanlıyız ve beğeneceğinizi umuyoruz.\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" +"\n" +"Lütfen e-posta adresinizi doğrulayın: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" +"\n" +"Bir sorunuz mu var? Yardıma ihtiyacınız olursa %(support_email)s adresinden " +"bize ulaşın.\n" +"--\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"Hesabınızı bu servisten silebilirsiniz: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Taigalandınız!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

E-posta adresinizi doğrulayın

\n" +"

Merhaba %(full_name)s,
lütfen e-posta adresinizi doğrulayın

\n" +" E-posta " +"adresini doğrula\n" +"

Siz istemediyseniz bu mesajı yok sayabilirsiniz.

\n" +"

%(signature)s

\n" +" " + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"Merhaba %(full_name)s, lütfen e-posta adresinizi doğrulayın\n" +"\n" +"%(url)s\n" +"\n" +"Siz istemediyseniz bu mesajı yok sayabilirsiniz.\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "[Taiga] E-posta adresini doğrula" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "Geçersiz" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Geçersiz kullanıcı adı. Farklı birşeyle yeniden deneyin." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "Yeni şartlar okundu doğru olmalı" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" +"Yinelenen anahtar değeri benzersiz kısıtlamasını ihlal ediyor. '{}' anahtarı " +"zaten var." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "anahtar" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "gizli anahtar" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "durum kodu" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "istek verisi" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "istek başlıkları" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "cevap verisi" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "cevap başlıkları" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "süre" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "İzin verilmeyen IP adresi" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "Geçersiz aşama kimliği. Aşama, aynı projeye ait olmalıdır." + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "" +#~ "Geçersiz kullanıcı hikayesi kimlikleri. Tüm hikayeler aynı projeye ve " +#~ "varsa aynı duruma ve aşamaya ait olmalıdır." + +#~ msgid "Personal info" +#~ msgstr "Kişisel bilgi" + +#~ msgid "Permissions" +#~ msgstr "İzinler" + +#~ msgid "Restrictions" +#~ msgstr "Kısıtlamalar" + +#~ msgid "Important dates" +#~ msgstr "Önemli tarihler" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/uk/LC_MESSAGES/django.po b/taiga/locale/uk/LC_MESSAGES/django.po new file mode 100644 index 000000000..0cc260b22 --- /dev/null +++ b/taiga/locale/uk/LC_MESSAGES/django.po @@ -0,0 +1,4805 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Boden , 2017-2020 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-11-05 03:35+0000\n" +"Last-Translator: Tymofii Lytvynenko \n" +"Language-Team: Ukrainian \n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != " +"11 ? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % " +"100 > 14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || " +"(n % 100 >=11 && n % 100 <=14 )) ? 2: 3);\n" +"X-Generator: Weblate 4.9-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" +"Ви повинні погодитись із нашими умовами обслуговування та політикою " +"конфіденційності" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "не вірний тип реєстрації" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "Не знайдено" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "не вірне ім'я користувача" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "Обов'язкове. Не більше 255 символів. Літери, цифри та символи /./-/_" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Вказане ім'я уже використовується." + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Електронна пошта уже використовується." + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Користувач уже зареєстрований." + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "користувач" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Це обов'язкове поле." + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "Не вірне значення." + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "" + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Введіть правильну електронну пошту." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "Введіть число повністю." + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "Введіть число." + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "Переконайтесь, що усього не більше %s символів." + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "Переконайтесь, що не більше %s десяткових знаків." + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "Переконайтесь, що не більше %s знаків перед десятковою крапкою." + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "Файл не надіслано. Перевірте тип кодування на формі." + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Файл не надіслано." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Надісланий файл порожній." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" +"Переконайтесь, що довжина імені файлу щонайбільше %(max)d символів (зараз " +"%(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" +"Завантажте правильне зображення. Файл, який Ви надіслали, не є зображенням " +"або пошкоджене зображення." + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "Заблокований елемент" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "Не правильна сторінка (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Не правильне визначення доступу." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "Не вірний ключ '%s' - об'єкт не існує." + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "Об'єкт з %s=%s не існує." + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Не коректні дані" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "Нічого не введено" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "Не можливо створити новий запис, можна лише оновити існуючі." + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "Очікується список елементів." + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "Не знайдено" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "Доступ заборонено" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "Помилка на стороні сервера" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "Помилка з'єднання." + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "Не коректні дані аутентифікації." + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "Не вказано дані аутентифікації." + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "У Вас немає прав для виконання цієї дії." + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "Метод '%s' не дозволений." + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Не очікувана помилка" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Не знайдено." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Не вірні аргументи." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "Помилка перевірки даних" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "Не залишилось місця для інших проєктів." + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'проєкт' повинно бути цілим числом." + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "Відслідковуйте нас у Twitter" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "Оновлення" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" Коментар: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "Помилка доступу до хоста" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "Помилка IP адреси" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "Історію користувача створено" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "Історію користувача змінено" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "Історію користувача видалено" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "ІК #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Завдання створено" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Завдання змінено" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Завдання видалено" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "Завдання #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "Проблему створено" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "Проблему змінено" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "Проблему видалено" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "Проблема: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "Сторінку Вікі створено" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "Сторінку Вікі змінено" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "Сторінку Вікі видалено" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "Сторінка Вікі : {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "Спринт створено" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "Спринт змінено" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "Спринт видалено" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "Спринт: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "Потрібна принаймні одна роль" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "Потрібний резервний файл" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "помилка імпортування проєктних даних" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "помилка імпортування ролей" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "помилка імпортування членства" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "помилка імпортування списку проєктних характеристик" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "помилка імпортування спринтів" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "помилка імпортування проблем" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "помилка імпортування історій користувача" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "помилка імпортування епіків" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "помилка імпортування завдань" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "помилка імпортування сторінок Вікі" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "помилка імпортування посилань Вікі" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "помилка імпортування міток" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "помилка імпортування графіків часу" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "неочікувана помилка імпортування проєкту" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" не знайдено в цьому проєкті" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "Невірний вміст. Має бути {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "Вимагається аутентифікація" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "ім'я" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "опис" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "додаток" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Не вірний токен." + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "повне ім'я" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "адреса електронної пошти" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "коментар" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "дата створення" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Додаткова інформація" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Додаткова інформація:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] Відгук від %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Проєкт не існує" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "не відомий користувач" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "Статус не існує" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "Переглянути проєкт" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "Переглянути епік" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "Переглянути історії користувачів" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "Переглянути завдання" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "Переглянути проблеми" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "Переглянути сторінки Вікі" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "Переглянути посилання Вікі" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Додати епік" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Змінити епік" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Видалити епік" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "Переглянути історію користувача" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "Додати історію користувача" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "Змінити історію користувача" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "Видалити історію користувача" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Додати завдання" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Змінити завдання" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Видалити завдання" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "Додати проблему" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "Змінити проблему" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "Вилучити проблему" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "Це обов'язкове поле." + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The project doesn't exist" +msgid "User does not exist." +msgstr "Проєкт не існує" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Цей користувач уже є членом проєкту." + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "нова адреса електронної пошти" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "біографія" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "" + +#: taiga/users/models.py:142 +msgid "date cancelled" +msgstr "" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "типова мова" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "типова тема" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "типова часова зона" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "розфарбувати мітки" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "нова адреса електронної пошти" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Ім'я користувача або пароль не відповідають користувачеві." + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Зміна електронної пошти" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Відновлення паролю" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/vi/LC_MESSAGES/django.po b/taiga/locale/vi/LC_MESSAGES/django.po new file mode 100644 index 000000000..56102d161 --- /dev/null +++ b/taiga/locale/vi/LC_MESSAGES/django.po @@ -0,0 +1,4812 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Đỗ Tiến Điệp (Việt Kids Plus) , 2018 +# Do Chinh , 2019 +# Đỗ Tiến Điệp (Việt Kids Plus) , 2018 +# leminhhai , 2018 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2021-02-26 11:31+0000\n" +"Last-Translator: Taiga Dev Team \n" +"Language-Team: Vietnamese (http://www.transifex.com/taiga-agile-llc/taiga-" +"back/language/vi/)\n" +"Language: vi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 2.3\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "dạng đăng nhập không hợp lệ" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found." +msgid "User not found" +msgstr "Không tìm thấy." + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "Tên tài khoản không hợp lệ" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "" +"Yêu cầu. 255 ký tự trở xuống. Bao gồm cả chữ cái, số hoặc ký tự /./-/_ '" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "Tên tài khoản đã được sử dụng" + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "Email này đã được sử dụng" + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "Tài khoản đã được đăng ký" + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "người dùng" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "Giá trị bắt buộc" + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "giá trị không hợp lệ" + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' phải có giá trị là True hoặc False." + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "" +"Nhập giá trị 'slug' hợp lệ chỉ bao gồm chữ cái, số, dấu gạch dưới hoặc dấu " +"gạch ngang." + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "" +"Chọn một lựa chọn hợp lệ. %(value)s không phải là một trong những lựa chọn " +"có sẵn." + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "Địa chỉ email của bạn không được phép sử dụng" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "Nhập một địa chỉ email hợp lệ." + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "" + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "" + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "" + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "" + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "" + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "Không có tệp tin nào được gửi đi." + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "Tệp tin mà bạn vừa gửi trống." + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "" + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "Định nghĩa quyền không hợp lệ." + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "Dữ liệu không hợp lệ" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "" + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "Lỗi không mong muốn" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "Không tìm thấy." + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "Sai đối số." + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] Các cập nhật" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "Công việc đã được tạo" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "Công việc đã thay đổi" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "Công việc đã xóa" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "mô tả" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "ứng dụng" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "Mã token không hợp lệ" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "tên đầy đủ" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "địa chỉ email" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "bình luận" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "ngày tạo" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "Thông tin thêm" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "- Thông tin thêm:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "Dự án không tồn tại" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "người dùng không xác định" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "Cần có tham số dự án" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "Thêm epic" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "Sửa epic" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "Xóa epic" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "Thêm công việc" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "Sửa công việc" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "Bình luận công việc" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "Xóa công việc" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "Sửa dự án" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "Xóa dự án" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "Thêm thành viên" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "Xóa thành viên" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "Giá trị mặc định" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "Đặt là public" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "Đặt là riêng tư" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "Thông tin chưa điền" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "Định dạng ảnh không hợp lệ" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "Giá trị bắt buộc" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "Xóa tệp đính kèm" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "Chưa phân công" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "Đã thêm" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "Đã thay đổi" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "Đã xóa" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "Từ:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "Tới:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "tạo lúc" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "phiên bản" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The tag doesn't exist." +msgid "User does not exist." +msgstr "Tag không tồn tại." + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "Tài khoản này đã là thành viên của dự án" + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "địa chỉ email mới" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "màu cho các tags" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "Tag này đã tồn tại." + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "Tag không tồn tại." + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "ai đó" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "Đồng ý lời mời của bạn tham gia Taiga" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "Đồng ý lời mời của bạn" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "Đồng ý lời mời tới Taiga bằng liên kết dưới đây:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "Tiếp tục" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "Tới thiết lập dự án của bạn:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "Mới" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "Sẵn sàng" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "Đang làm" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "Sẵn sàng để test" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "Hoàn thành" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "Đã lưu trữ" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "Đã đóng" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "Câu hỏi" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "ngày hoàn thành" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "Lượt bình chọn" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "Bình chọn" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "lần thay đổi cuối" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +msgid "Memberships" +msgstr "" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +msgid "Private projects owned" +msgstr "" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +msgid "Public projects owned" +msgstr "" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "Email bị trùng" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "Tên người dùng hoặc email không hợp lệ" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "Mật khẩu hiện tại không hợp lệ" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "" +"Không hợp lệ, bạn có chắc token là đúng và bạn chưa sử dụng nó trước đây?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "Không hợp lệ, bạn có chắc token là đúng không?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "tình trạng quản lý" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "tên đăng nhập" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "Bắt buộc. Tối đa 30 kí tự chữ cái, chữ số và /./-/_" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "Nhập vào tên đăng nhập hợp lệ" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "hoạt động" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "tiểu sử" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "hình ảnh" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "ngày tham gia" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "ngày tham gia" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "ngôn ngữ mặc định" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "múi giờ mặc định" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "tag màu sắc" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "email token" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "địa chỉ email mới" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "Số lượng dự án riêng tư tối đa mà người dùng có thể tạo" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "Số lượng dự án mở tối đa mà người dùng có thể tạo" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "số lượng thành viên tối đa cho mỗi dự án riêng tư mà người dùng sở hữu" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "số lượng thành viên tối đa cho mỗi dự án mở mà người dùng sở hữu" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "quyền" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "Tên đăng nhập và mật khẩu không khớp" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] Thay đổi email" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] Khôi phục mật khẩu" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "Bạn đã đăng ký thành công Taiga!" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "không hợp lệ" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "Tên đăng nhập không hợp lệ. Hãy thử lại." + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "Khóa '{}' đã được sử dụng." + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "khóa" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "khóa bí mật" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "mã trạng thái" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "dữ liệu yêu cầu" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "tiêu đề yêu cầu" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "dữ liệu phản hồi" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "tiêu đề phản hồi" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "thời gian" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "Địa chỉ IP không cho phép" + +#~ msgid "Personal info" +#~ msgstr "Thông tin cá nhân" + +#~ msgid "Permissions" +#~ msgstr "Quyền hạn" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/zh_Hans/LC_MESSAGES/django.po b/taiga/locale/zh_Hans/LC_MESSAGES/django.po new file mode 100644 index 000000000..c527f26b8 --- /dev/null +++ b/taiga/locale/zh_Hans/LC_MESSAGES/django.po @@ -0,0 +1,5156 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# 5791113 , 2016 +# Andy zhan , 2018 +# Ares , 2017 +# Ares , 2017 +# gm l , 2016 +# Hanbing Yin , 2016 +# ifelse , 2015 +# Lincan Li , 2017 +# Longyang Zhang , 2015 +# Qi Fan , 2016 +# 青草 , 2017 +# S Sun , 2019 +# weetao , 2019 +# waring id , 2016 +# weetao , 2019 +# wuwenbin , 2015 +# Yang Yu , 2016 +# yonee , 2015 +# 5791113 , 2016 +# zangyg , 2018 +# zangyg , 2018 +# 5791113 , 2016 +# 朱坚 , 2017 +# 青草 , 2017 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2024-03-31 15:01+0000\n" +"Last-Translator: \"ReinWD (ReinWD)\" \n" +"Language-Team: Chinese (Simplified) \n" +"Language: zh_Hans\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 5.5-dev\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "无效的登录类型" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "公开注册被禁用。" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "您需要接受我们的服务与隐私条款" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "不合法的注册类型" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "校验头必须包含两个空格分隔的值" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "口令类型不匹配任何可用的口令类型" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "口令未包含任何可用的用户信息" + +#: taiga/auth/authentication.py:147 +msgid "User not found" +msgstr "找不到用户" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "用户不活跃" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "未知算法类型 '{}'" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "您必须安装加密组件以使用'{}'。" + +#: taiga/auth/backends.py:128 +msgid "Invalid algorithm specified" +msgstr "无效的算法类型" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +msgid "Token is invalid or expired" +msgstr "令牌无效或已经过期" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "无效用户名" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "必填,长度小于255的数字、英文字母、“-”、“_”" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "不合法的全名" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "给出的凭证未匹配到任何可用账户" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "用户名已经被使用" + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "电子邮件已经被使用" + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "用户已注册" + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "在创建新用户时出现错误。" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "用户" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "创建于" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "过期于" + +#: taiga/auth/token_denylist/apps.py:74 +msgid "Token Denylist" +msgstr "令牌黑名单" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "无法创建无类型或有效期的令牌" + +#: taiga/auth/tokens.py:127 +msgid "Token has no id" +msgstr "令牌无对应id" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "令牌无类型" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "令牌类型错误" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "令牌缺失声明: '{}'" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "令牌声明 '{}' 已经过期" + +#: taiga/auth/tokens.py:225 +msgid "Token is denylisted" +msgstr "令牌在黑名单内" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "此字段是必需的。" + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "无效值。" + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' 值必须为True或False。" + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "输入一个有效的字符,由字母、数字、下划线或横线组成" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "请选择一个有效的选项。%(value)s 不是一个可用的选项。" + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "不允许您的邮件域名" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "请输入合法的邮件地址。" + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "日期格式错误。使用以下一种格式替代:%s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "日期格式错误。使用以下一种格式替代:%s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "时间格式错误。使用以下一种格式替代:%s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "输入一个整数" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "确认此数值应小于或等于 %(limit_value)s" + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "确认此数值应大于或等于 %(limit_value)s" + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "'%s' 值必须为 浮点数。" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "输入一个数字" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "确保总数不超过%s个数字" + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "确保小数不超过%s位" + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "确保小数点前不超过%s位数字" + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "未提交任何文件。请检查表单的编码类型" + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "没有文件提交" + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "提交的文件是空的" + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "确保文件名不超过 %(max)d 个字符 (当前 %(length)d 个)" + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "请提交文件或选中清除复选框,二选一" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "请上传一张有效的图片。所上传的不是图片或已损坏" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "冻结的元素" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "页面不是‘最后’一个, 也不能转换成整数。" + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "无效页面 (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "无效的授权定义" + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "无效主键 '%s' - 对象不存在" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "错误的类型。期望收到主键, 但是收到了'%s'" + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr "没有满足条件的结果: %s=%s" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "无效的超链接 - 无匹配的URL地址" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "无效的超链接 - 错误的URL匹配" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "无效的超链接 - 配置错误" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "无效的超链接 - 对象不存在" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "错误的数据类型。期望收到URL, 但是收到了 %s" + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "无效的数据" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "没提供输入数据" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "不能创建新项目,可能只是现有项目被修改。" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "期望一个项目列表" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "找不到" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "无此权限" + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "服务器端错误" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "连接错误" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "错误请求" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "用户名密码验证错误" + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "密码未提供" + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "你未被授权做此操作" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "不允许用此方法:'%s'" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "请求的Header头不允许" + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "媒体类型'%s'不支持" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "请求被限制" + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "预期可在%d 秒%s." + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "异常错误" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "未找到" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "请求方法不支持" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "错误参数" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "数据验证错误" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "参数缺失或非法" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "前提条件错误" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "没空间添加新项目了" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s 不是一个列表" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "参数类型错误" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "'project'必须是一个整数值" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "您已成为Taiga的一员" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

欢迎使用 " +"%(product_name)s,一个开源的,敏捷项目管理工具

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" 配置邮件通知或取消订阅\n" +"  • \n" +" Taiga 支持\n" +"  • \n" +" 联系我们\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "在推特上关注我们" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "从Github上获取代码" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] 更新" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "更新" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

评论:" +"

\n" +"

%(comment)s\n" +" " + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +" 评论: %(comment)s\n" +" " + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "访问主机错误" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "IP地址错误" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "用户故事已建立" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "用户故事已修改" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "用户故事已删除" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "用户故事 #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "任务已建立" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "任务已修改" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "任务已删除" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "任务 #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "问题已建立" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "问题已修改" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "问题已删除" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "议题: #{} - {}" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "维基页面已建立" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "维基页面已修改" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "维基页面已删除" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "维基页面: {}" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "冲刺任务已建立" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "冲刺任务已修改" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "冲刺任务已删除" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "冲刺任务: {}" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "我们至少需要1个角色" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "需要备份文件" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "非法的备份文件格式" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "无法导入项目数据" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "角色导入错误" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "成员关系导入错误" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "项目属性列表导入错误" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "导入默认项目属性值时出现错误" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "客户化属性导入错误" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "sprints导入错误" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "问题导入错误" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "用户故事导入错误" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "史诗导入错误" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "任务导入错误" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "维基页面导入错误" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "维基链接导入错误" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "标签导入错误" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "时间线导入错误" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "导入项目发生未知错误" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "你拥有的私有项目已到上限" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "该项目已达到你设置的私有项目最大成员数" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "你拥有的公开项目已到上限" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "该项目已达到你设置的公开项目最大成员数" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "生成项目备份文件出错" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" +"\n" +"\n" +"按名字读取备份错误:{user_full_name} <{user_email}>\n" +"\n" +"原因:\n" +"---------\n" +"{reason}\n" +"\n" +"细节:\n" +"---------\n" +"{details}\n" +"\n" +"错误跟踪:\n" +"---------" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "读取项目备份错误" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "读取你的项目备份文件错误" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "-- 无详细信息 --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

项目导出已生成

\n" +"

你好 %(user)s,

\n" +"

项目 %(project)s 导出已经被正确生成。

\n" +"

你可以从这里下载:

\n" +" 下" +"载导出的文件\n" +"

这个文件将在 %(deletion_date)s.被删除。

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"你好 %(user)s,\n" +"\n" +"项目 %(project)s 的导出文件已经正确生成。你可以从这里下载:\n" +"\n" +"%(url)s\n" +"\n" +"文件将会在 %(deletion_date)s 被删除。\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] 您的项目备份文件已经生成" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

你好 %(user)s,

\n" +"

你的项目 %(project)s 没有被正确导出。

\n" +"

Taiga 系统管理员已经接收到相关通知。
请再试一次,或者联系下方的支" +"持团队\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"用户 %(user)s 您好:\n" +"\n" +"%(error_message)s\n" +"您的项目 %(project)s 未能成功导出。\n" +"\n" +"Taiga 系统管理员已收到通知。\n" +"\n" +"请重试或联系您的支持团队:%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" +"\n" +"

%(error_message)s

\n" +"

您好, %(user)s

\n" +"

您的项目未能成功导入。

\n" +"

项目%(product_name)s 的管理员已经收到通知。
" +"请重试或者联系您的支持团队:\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

错误详细信息:

\n" +"
%(details)s
\n" +" " + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"您好,%(user)s\n" +"\n" +"%(error_message)s\n" +"\n" +"您的项目未能成功导入\n" +"\n" +"%(product_name)s的系统管理员已经收到通知。\n" +"\n" +"请重试或者联系您的支持团队:%(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

项目dump已成功导入

\n" +"

您好,%(user)s

\n" +"

您的项目dump已成功导入

\n" +" 前往您的项目%(project)s\n" +"

%(signature)s

\n" +" " + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"您好,%(user)s\n" +"\n" +"您的项目dump已经成功导入。\n" +"\n" +"您可以通过以下链接访问您的项目 %(project)s \n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] 项目备份文件已经被导出" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" 在这个项目没找到" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "内容非法. 正确格式 {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "它包含无效的自定义字段。" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "命名重复" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "存在一个史诗包含外部项目(%(project)s),无法导入" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "需要身份认证" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "名称" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "图标地址" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "网页" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "描述" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "下一个URL" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "应用" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "无效令牌" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "全名" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "邮件地址" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "评论" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "创建日期" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

反馈

\n" +"

Taiga收到%(full_name)s <%(email)s>的反馈

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

评论

\n" +"

%(comment)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "更多信息" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, fuzzy, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" +"\n" +"

%(signature)s

\n" +" " + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- 来自: %(full_name)s <%(email)s>\n" +"---------\n" +"- 评论:\n" +"%(comment)s\n" +"---------" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr " - 更多信息:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, fuzzy, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] 反馈来自%(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "payload内容不是一个有效的json" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "项目不存在" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "签名错误" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" +"[{user_name}]({user_url} \"查看{user_name}的 {platform} 档案\") 提到 " +"[{platform}#{number}]({comment_url} \"前往该评论\"):\n" +"\n" +"\"{comment_message}\"" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"评论来自 {platform}:\n" +"\n" +"> {comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "无效的问题评论信息" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "问题创建自{platform}." + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "无效的问题信息" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "未知用户" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"{user_text} 已经从 [{platform} commit] 修改了状态({commit_url} \"查看提交 " +"'{commit_id} - {commit_short_message}'\")\n" +"\n" +"-状态:**{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" +"改变状态,从 {platform} 提交.\n" +"\n" +" - 状态: **{src_status}** → **{dst_status}**" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" +"这个{type_name}已经被{user_text}在[{platform} commit]({commit_url}中提到 \"查" +"看提交 '{commit_id} - {commit_short_message}'\") \"{commit_message}\"" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "在 {platform} 的提交\"{commit_message}\"中提到这个特性" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "引用的元素不存在" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "状态不存在" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "该项目参数必填" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "无效的Asana API请求" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "构造Asana API的请求失败" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "代码参数必填" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "导入Asana项目错误" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "无效用户ID" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "第三方服务失败" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "导入GitHub项目错误" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "这个url参数必填" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "无效的项目类型 {}" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "Jira 服务器配置失败" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "无效或失效的账户token" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "导入JIRA项目错误" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "导入PivotalTracker项目错误" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "[%(project)s] 你的 Asana 项目已经被导入" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s] 你的 Github 项目已经被导入" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "[%(project)s]你的Jira项目已经被导入" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "[%(project)s] 你的Trello项目已经被导入" + +#: taiga/importers/trello/importer.py:57 +#, fuzzy, python-format +#| msgid "Invalid Request: %s at %s" +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "无效请求:%s在%s" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, fuzzy, python-format +#| msgid "Unauthorized: %s at %s" +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "未授权:%s在%s" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, fuzzy, python-format +#| msgid "Resource Unavailable: %s at %s" +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "资源不可用:%s在%s" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "导入Trello项目的时候出错" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "查看项目" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "查看里程碑" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "查看史诗" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "查看用户故事" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "查看任务" + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "查看问题" + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "查看维基页" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "查看维基链接" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "新增里程碑" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "修改里程碑" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "删除里程碑" + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "添加史诗" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "修改史诗" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "评论史诗" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "删除史诗" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "查看用户故事" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "新增用户故事" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "修改用户故事" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "评论用户故事" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "删除用户故事" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "新增任务" + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "修改任务" + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "评论任务" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "删除任务" + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "新增问题" + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "修改问题" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "评论问题" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "删除问题" + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "新增维基页" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "修改维基页" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "评论维基页" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "删除维基页" + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "新增维基链接" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "修改维基页" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "删除维基链接" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "修改项目" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "删除项目" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "增加新成员" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "删除成员" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "管理项目" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "管理角色" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "模块" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "预设值" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "动态" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "粉丝" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "所有者" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "公开" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "{count} 成功公开。" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "私有" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "{count} 成功的私有。" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "不完整的参数" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "非法的图片格式" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "无效用户ID" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "用户不存在" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "comment is required" +msgid "Email is required" +msgstr "需要评论" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "该项目必须有一个所有者,至少一个用户必须是一个积极的管理员" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "不支持部分更新" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "项目" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "内容类别" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "对象id" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "修改日期" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "附加文件" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "SHA1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "已废弃" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "来自评论" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "次序" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +#, fuzzy +#| msgid "" +#| "Invalid task ids. All tasks must belong to the same project and, if it " +#| "exists, to the same status, user story and/or milestone." +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "任务ID无效,所有任务必须属于同一项目。" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "自定" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "这个项目因为交付失败被封锁" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "这个项目被管理员封锁" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "这个因为所有者离开被封锁" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "这个项目因为删除被封锁" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" +"\n" +"%(full_name)s已写入" +"到%(project_name)s" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" +"\n" +" 你接收到这个消息是因为你被添加到了项目%(project_name)s的管理员列表" +"中。如果你不希望Taiga社区接触到你的项目,请更新你的项目设置来阻止这些接触。项目成" +"员间的常规通信不受影响。" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" +"\n" +"%(full_name)s已写入到%(project_name)s\n" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" +"\n" +" 你接收到这个消息是因为你被添加到了项目%(project_name)s的管理员列表" +"中。如果你不希望Taiga社区接触到你的项目,请在%(project_settings_url)s中更新项" +"目设置来阻止这些接触。项目成员间的常规通信不受影响。\n" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" +"\n" +"[Taiga] %(full_name)s已经向项目%(project_name)s 发送了一条消息\n" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "单行文字" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "多行文字" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "富文本" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "日期" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "链接" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "类型" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "值" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "史诗" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "用户故事" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "任务" + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "问题" + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "已经存在" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "截止日期" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "截止日期的原因" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "你无权设置这个史诗的状态" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "参照" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "状态" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "史诗次序" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "主题" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "颜色" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "指派给" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "客户需求" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "团队需求" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "用户故事" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "外部引用" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "指定ID无史诗" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "需要评论" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "删除的评论无法编辑" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "评论已删除" + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "评论未删除" + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "变更" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "创建" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "删除" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s 角色权重" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "来自" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "至" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "添加新的附件" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "更新附件" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "已废弃" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "未废弃" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "删除附件:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "已加入" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "移除" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "未指派" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "未设置" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-删除-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "发送给:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "来自:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "已添加" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "已变更" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "已删除" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "已添加:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "已移除:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "来自:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "发送给:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "内容" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "封锁的笔记" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "冲刺任务" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "您没有权限设置此冲刺到这一问题。" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "你无权设置此状态到这个问题。" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "您没有权限设置此严重程度这一问题。" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "您没有权限设置此优先级这一问题。" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "您没有权限设置此类型这一问题。" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "严重程度" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "优先级" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "里程碑" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "完成日期" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "此里程碑在该项目无效" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "点赞" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "点赞" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "代称" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "预估开始日期" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "预估结束日期" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "是关闭的" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "不受约束" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "预估的开始日期必须早于完成日期" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "此ID无里程碑" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "所有用户故事必须从属于一个同一项目" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "封锁" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "ref 参数必需" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "需要project 或者project_slug" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' 参数必填" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project' 参数必填" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "用户必须为项目成员。" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "电子邮件" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "创建自" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "令牌" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "需要更多的文本内容" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "用户故事次序" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "该用户已经是此项目成员。" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "默认史诗状态" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "默认用户故事状态" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "默认点数" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "默认任务状态" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "默认优先级" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "默认严重程度" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "默认问题状态" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "默认问题类别" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "标志" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "成员" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "里程碑总数" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "总故事点数" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "主动联系" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "活动史诗面板" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "活动积压面板" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "活动看板面板" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "活动维基面板" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "活动问题面板" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "视频会议系统" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "视像会议的额外数据" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "创建模板" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "私有的" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "匿名权限" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "用户权限" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr "被推荐" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "招募成员" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "查找用户注解" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "项目转让令牌" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "已锁定的代码" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "更新的日期时间" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "计数" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "上周粉丝" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "上月粉丝" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "去年粉丝" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "上周活动" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "上月活动" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "去年活动" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "模块配置" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "已归档" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "工作进度限制" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "值" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "默认" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "截止天数" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "默认所有者角色" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "默认选项" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "史诗状态" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "用户故事状态" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "用户故事截止日期" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "点数" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "任务状态" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "任务截止日期" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "问题状态" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "问题类型" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "问题截止日期" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "优先级" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "严重程度" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "角色" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "史诗自定义属性" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "用户故事自定义属性" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "任务自定义属性" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "问题自定义属性" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "涉入" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "所有" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "无" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "创建日期时间" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "历史记录条目" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "通知用户" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "关注" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "通知已存在" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "此通知级别该值非法" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" +"\n" +"史诗更新\n" +"您好,%(user)s, %(changer)s 更新了%(project)s项目的史诗\n" +"查看史诗#%(ref)s %(subject)s 从 %(url)s\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新史诗 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 创建史诗#%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 删除史诗 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新问题 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 新增问题 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新问题 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新冲刺任务 \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] 新增冲刺任务\"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] 删除冲刺任务 \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新任务 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 新增任务 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 删除任务 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新用户故事 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 新增用户故事 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 删除用户故事 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新维基页 \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] 创建维基页 \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] 删除维基页 \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "观察者保护无效用户" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "版本号必须是个整数值" + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "版本号参数无效" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "版本号与当前版本不匹配" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "版本" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "因没有其他项目管理者,项目所有者不能离开项目" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "所使用的令牌无法匹配到合法的邀请。" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The user doesn't exist" +msgid "User does not exist." +msgstr "用户不存在" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "该用户已经是此项目成员。" + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "新邮件地址" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "项目没有所有者" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "你已经达到了私有项目的最大成员数目" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "你已经达到了公开项目的最大成员数目" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "你已经达到当前待定成员的限制" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "未来的冲刺任务" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "项目结束" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "令牌无效" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "令牌已经过期" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "看板" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "无效标签 '{value}',该颜色不是一个合法的HEX颜色。" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" +"无效标签 '{value}'. 它必须是或部分是 '[\"name\", \"hex color/\" | null]'。" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "无效标签'{value}'. 它必须为标签名称。" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "标签" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "标签颜色" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "该标签已存在" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "该颜色不是一个合法的HEX颜色" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "该标签不存在" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "你无权对这个任务设置该冲刺任务。" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "你无权对这个任务设置该用户故事。" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "你无权对这个任务设置该状态。" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "用户故事次序" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "任务板次序" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "负予全新任务" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "无效的里程碑ID" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "无效的任务状态ID" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "无效的用户故事ID" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "任务状态ID无效,该状态必须属于同一项目。" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "用户故事ID无效,该用户故事必须属于同一项目。" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "里程碑ID无效,该里程碑必须属于同一项目" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "任务ID无效,所有任务必须属于同一项目。" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "某人" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "接受加入Taiga的邀请" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "接受你的邀请" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "接受加入Taiga邀请,从此链接:" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] 邀请您加入项目 '%(project)s'\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] 新增项目'%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "

%(new_owner_name)s 说:

" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" +"\n" +"

从现在开始, 你将成为该项目的\"admin\"。

\n" +" " + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "%(new_owner_name)s 说:" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" +"\n" +"从现在开始, 你将成为该项目的\"admin\"。\n" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" +"\n" +"[%(project)s] 项目所有权转让请求被接受!\n" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(rejecter_name)s 说:

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" +"\n" +"

如果你想, 你可以继续尝试将所有权转让给其他人。

\n" +" " + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "请求转让给其他人" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "%(rejecter_name)s 说:" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" +"\n" +"如果你想, 你可以继续尝试将所有权转让给其他人。\n" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "请求转让给其他人:" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" +"\n" +"[%(project)s] 拒绝项目所有权转让\n" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" +"\n" +"

如果你想转让项目所有权,请点击 \"Continue\" 继续。

\n" +" " + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "继续" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" +"\n" +"如果想从控制面板转让项目所有权,请跳转到项目设置。\n" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "跳转至项目设置:" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" +"\n" +"[%(project)s] 项目所有权转让请求\n" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" +"\n" +"

您好 %(receiver_name)s,

\n" +"

%(owner_name)s, 拥有 \"%(project_name)s的所有权,\" 希望将该所有权" +"转让给您.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" +"\n" +"

%(owner_name)s 说:

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" +"\n" +"

请点击 \"Continue\" 继续,或其他拒绝.

\n" +" " + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" +"\n" +"您好 %(receiver_name)s,\n" +"%(owner_name)s, 项目\"%(project_name)s\" 的所有者想将项目所有权转让给您。\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "%(owner_name)s 说:" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" +"\n" +"请访问下面的链接接受或拒绝该建议。

\n" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "接受或拒绝该项目所有权的转让:" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" +"\n" +"[%(project)s] 项目所有权转让请求\n" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "冲刺" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "新建" + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "准备好" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "进行中" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "待测试" + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "完成" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "归档" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "已关闭" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "需要更多信息" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "推迟" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "拒绝" + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "缺陷" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "疑问" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "增强" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "低" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "中" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "高" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "收藏" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "次要" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "重要" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "关键" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "用户体验" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "设计" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "字体" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "后退" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "产品所有者" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "相关人员" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "你无权对这个用户故事设置此冲刺任务。" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "你无权对这个用户故事设置此状态。" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "无效的角色ID '{role_id}'" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "无效的点数ID '{points_id}'" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "生成用户故事#{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "角色" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "待办任务先后次序" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "冲刺任务次序" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "看板次序" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "完成日期" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "被指派用户" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "从问题派生" + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "该ID无用户故事" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "用户故事ID无效,所有用户故事必须属于同一项目" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "用户故事ID无效,该用户故事必须属于同一项目。" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +#, fuzzy +#| msgid "" +#| "Invalid user story id. The user story must belong to the same project." +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "用户故事ID无效,该用户故事必须属于同一项目。" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "该ID无对应项目" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "该用户已经是项目成员。" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "此角色在该项目无效" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "该用户必须是有效的联系方式" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "项目所有者必须是管理者" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "一个项目至少有一名活跃的管理者" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "角色ID无效,所有角色必须属于同一项目" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "默认选项" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "用户故事状态" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "点数" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "任务状态" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "问题状态" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "问题类型" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "优先级" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "严重程度" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "角色" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "投票数" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "投票" + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content' 参数必需" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id' 参数必需" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "最后修改者" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "超链接" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "检查确切的 diff 的历史记录 API" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "项目成员" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "项目成员" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "id" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "项目所有权" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "项目所有权" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "成员" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "项目结束" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "项目结束" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "重复的邮件地址" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "用户名称或邮件地址无效" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "输入当前密码" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "输入新密码" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "当前密码无效" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "无效,请确定令牌正确,之前使用过?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "无效,请确定令牌正确?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "邮件发送成功!" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "超级用户状态" + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "选定此用户拥有所有权限,而不用显式将他们分配。" + +#: taiga/users/models.py:120 +msgid "username" +msgstr "用户名" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "必填,长度小于30的英文字母、数字、“.”、“-”、“_”" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "输入一个合法的用户名" + +#: taiga/users/models.py:127 +msgid "active" +msgstr "活跃" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "指定是否此用户为活跃用户。取消选择这而不是删除帐户。" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "个人简介" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "照片" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "加入日期" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "加入日期" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "已接受的条款" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "阅读新条款" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "默认语言" + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "默认主题" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "默认时区" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "彩色标签" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "电子邮件密码" + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "新邮件地址" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "最大私有项目数" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "最大公开项目数" + +#: taiga/users/models.py:172 +#, fuzzy +#| msgid "max number of memberships for each owned private project" +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "私有项目最大成员数" + +#: taiga/users/models.py:177 +#, fuzzy +#| msgid "max number of memberships for each owned public project" +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "公开项目最大成员数" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "权限" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "用户名或密码错误。" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga] 变更电邮地址" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] 找回密码" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +" 你可以注销账户从下面的服务 点击\n" +" " + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"您可以注销您的账户,通过: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "您已成为Taiga的一员" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" +"\n" +"你好 %(full_name)s,请验证你的邮件\n" +"\n" +"%(url)s\n" +"\n" +"如果你没有发起注册请求,请忽略此条消息。\n" +"\n" +"---\n" +"%(signature)s\n" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "[Taiga] 验证邮件" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "无效的" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "无效用户名,请尝试其他的。" + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "还没有阅读新条款" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "重复的键值违反唯一约束。键 '{}' 已经存在。" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "键" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "网址" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "密钥" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "状态码" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "请求数据" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "请求Header头" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "响应数据" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "响应Header头" + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "持续时间" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "IP地址被禁用" + +#~ msgid "Invalid milestone id. The milistone must belong to the same project." +#~ msgstr "里程碑ID无效,此里程碑必须属于同一项目" + +#~ msgid "" +#~ "Invalid user story ids. All stories must belong to the same project and, " +#~ "if it exists, to the same status and milestone." +#~ msgstr "用户故事ID无效,所有用户故事必须属于同一项目" + +#~ msgid "Personal info" +#~ msgstr "个人信息" + +#~ msgid "Permissions" +#~ msgstr "权限" + +#~ msgid "Restrictions" +#~ msgstr "限制" + +#~ msgid "Important dates" +#~ msgstr "重要日期" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/locale/zh_Hant/LC_MESSAGES/django.po b/taiga/locale/zh_Hant/LC_MESSAGES/django.po new file mode 100644 index 000000000..19cf83719 --- /dev/null +++ b/taiga/locale/zh_Hant/LC_MESSAGES/django.po @@ -0,0 +1,4909 @@ +# taiga-back.taiga. +# Copyright (C) 2014-2017 Taiga Dev Team +# This file is distributed under the same license as the taiga-back package. +# +# Translators: +# Translators: +# Alvis , 2015 +# BlueT - Matthew Lien - 練喆明 , 2015 +# Chi-Hsun Tsai, 2015-2016 +# David Barragán , 2015 +# hori.liu , 2017 +# BlueT - Matthew Lien - 練喆明 , 2015 +# sammy huang , 2017 +msgid "" +msgstr "" +"Project-Id-Version: taiga-back\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-07 20:31+0000\n" +"PO-Revision-Date: 2022-09-18 16:16+0000\n" +"Last-Translator: Steven Huang \n" +"Language-Team: Chinese (Traditional) \n" +"Language: zh-TW\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 4.14.1\n" + +#: taiga/auth/api.py:79 +msgid "invalid login type" +msgstr "無效的登入類型" + +#: taiga/auth/api.py:108 +msgid "Public registration is disabled." +msgstr "暫不開放註冊。" + +#: taiga/auth/api.py:131 +msgid "You must accept our terms of service and privacy policy" +msgstr "您必須要接受我們的服務條款及隱私政策" + +#: taiga/auth/api.py:140 +msgid "invalid registration type" +msgstr "無效的註冊類型" + +#: taiga/auth/authentication.py:110 +msgid "Authorization header must contain two space-delimited values" +msgstr "" + +#: taiga/auth/authentication.py:131 +msgid "Given token not valid for any token type" +msgstr "" + +#: taiga/auth/authentication.py:142 taiga/auth/authentication.py:164 +msgid "Token contained no recognizable user identification" +msgstr "" + +#: taiga/auth/authentication.py:147 +#, fuzzy +#| msgid "Not found" +msgid "User not found" +msgstr "找不到" + +#: taiga/auth/authentication.py:150 +msgid "User is inactive" +msgstr "" + +#: taiga/auth/backends.py:91 +msgid "Unrecognized algorithm type '{}'" +msgstr "" + +#: taiga/auth/backends.py:94 +msgid "You must have cryptography installed to use {}." +msgstr "" + +#: taiga/auth/backends.py:128 +#, fuzzy +#| msgid "Invalid role for the project" +msgid "Invalid algorithm specified" +msgstr "專案無效的角色" + +#: taiga/auth/backends.py:130 taiga/auth/exceptions.py:69 +#: taiga/auth/tokens.py:75 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is invalid or expired" +msgstr "代號無效" + +#: taiga/auth/serializers.py:80 taiga/users/validators.py:37 +msgid "invalid username" +msgstr "無效使用者名稱" + +#: taiga/auth/serializers.py:85 taiga/users/validators.py:43 +msgid "" +"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'" +msgstr "必填。最多255字元(可為數字,字母,符號....)" + +#: taiga/auth/serializers.py:92 taiga/auth/serializers.py:95 +#: taiga/users/validators.py:56 taiga/users/validators.py:59 +msgid "Invalid full name" +msgstr "無效的姓名" + +#: taiga/auth/services.py:73 +msgid "No active account found with the given credentials" +msgstr "" + +#: taiga/auth/services.py:136 +msgid "Username is already in use." +msgstr "本用戶名稱已被註冊。" + +#: taiga/auth/services.py:139 +msgid "Email is already in use." +msgstr "本電子郵件已使用。" + +#: taiga/auth/services.py:172 +msgid "User is already registered." +msgstr "使用者已被註冊。" + +#: taiga/auth/services.py:203 +msgid "Error while creating new user." +msgstr "建立新使用者時發生錯誤。" + +#: taiga/auth/token_denylist/admin.py:103 +msgid "jti" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:110 taiga/external_apps/models.py:47 +#: taiga/projects/contact/models.py:17 taiga/projects/likes/models.py:23 +#: taiga/projects/notifications/models.py:95 taiga/projects/votes/models.py:44 +msgid "user" +msgstr "使用者" + +#: taiga/auth/token_denylist/admin.py:117 taiga/telemetry/models.py:21 +msgid "created at" +msgstr "" + +#: taiga/auth/token_denylist/admin.py:124 +msgid "expires at" +msgstr "" + +#: taiga/auth/token_denylist/apps.py:74 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token Denylist" +msgstr "代號無效" + +#: taiga/auth/tokens.py:61 +msgid "Cannot create token with no type or lifetime" +msgstr "" + +#: taiga/auth/tokens.py:127 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token has no id" +msgstr "代號無效" + +#: taiga/auth/tokens.py:138 +msgid "Token has no type" +msgstr "" + +#: taiga/auth/tokens.py:141 +msgid "Token has wrong type" +msgstr "" + +#: taiga/auth/tokens.py:178 +msgid "Token has no '{}' claim" +msgstr "" + +#: taiga/auth/tokens.py:182 +msgid "Token '{}' claim has expired" +msgstr "" + +#: taiga/auth/tokens.py:225 +#, fuzzy +#| msgid "Token is invalid" +msgid "Token is denylisted" +msgstr "代號無效" + +#: taiga/base/api/fields.py:283 +msgid "This field is required." +msgstr "此欄位是必要的。" + +#: taiga/base/api/fields.py:284 taiga/base/api/relations.py:326 +msgid "Invalid value." +msgstr "無效的數值。" + +#: taiga/base/api/fields.py:473 +#, python-format +msgid "'%s' value must be either True or False." +msgstr "'%s' 數值必須為「是」或「否」。" + +#: taiga/base/api/fields.py:538 +msgid "" +"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens." +msgstr "輸入有效的代稱,其包括字母,數字,底底線與連字符號。" + +#: taiga/base/api/fields.py:553 +#, python-format +msgid "Select a valid choice. %(value)s is not one of the available choices." +msgstr "請做個有效的選擇。 %(value)s 並不是可以選的選項。" + +#: taiga/base/api/fields.py:627 +msgid "You email domain is not allowed" +msgstr "您的電子郵件域名是不允許的" + +#: taiga/base/api/fields.py:636 +msgid "Enter a valid email address." +msgstr "輸入無效之電子郵件地址" + +#: taiga/base/api/fields.py:678 +#, python-format +msgid "Date has wrong format. Use one of these formats instead: %s" +msgstr "資料格式錯誤,請改用這些格式取代:%s" + +#: taiga/base/api/fields.py:742 +#, python-format +msgid "Datetime has wrong format. Use one of these formats instead: %s" +msgstr "日期格式錯誤,請使用這些格式取代:%s" + +#: taiga/base/api/fields.py:812 +#, python-format +msgid "Time has wrong format. Use one of these formats instead: %s" +msgstr "時間格式錯誤,請使用這些格式取代:%s" + +#: taiga/base/api/fields.py:869 +msgid "Enter a whole number." +msgstr "輸入一個整數" + +#: taiga/base/api/fields.py:870 taiga/base/api/fields.py:923 +#, python-format +msgid "Ensure this value is less than or equal to %(limit_value)s." +msgstr "確認此值小於等於 %(limit_value)s." + +#: taiga/base/api/fields.py:871 taiga/base/api/fields.py:924 +#, python-format +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "確認此值大於等於 %(limit_value)s." + +#: taiga/base/api/fields.py:901 +#, python-format +msgid "\"%s\" value must be a float." +msgstr "\"%s\" 數值必須為一個浮點數" + +#: taiga/base/api/fields.py:922 +msgid "Enter a number." +msgstr "輸入一組號碼" + +#: taiga/base/api/fields.py:925 +#, python-format +msgid "Ensure that there are no more than %s digits in total." +msgstr "確認全部沒有多於 %s位數 " + +#: taiga/base/api/fields.py:926 +#, python-format +msgid "Ensure that there are no more than %s decimal places." +msgstr "確認沒有多於 %s十進位數 " + +#: taiga/base/api/fields.py:927 +#, python-format +msgid "Ensure that there are no more than %s digits before the decimal point." +msgstr "確認在小數點前沒有多於 %s位數 " + +#: taiga/base/api/fields.py:994 +msgid "No file was submitted. Check the encoding type on the form." +msgstr "無檔案送出,請 確認表格中的編碼 格式" + +#: taiga/base/api/fields.py:995 +msgid "No file was submitted." +msgstr "無檔案送出" + +#: taiga/base/api/fields.py:996 +msgid "The submitted file is empty." +msgstr "送出的檔案無內容" + +#: taiga/base/api/fields.py:997 +#, python-format +msgid "" +"Ensure this filename has at most %(max)d characters (it has %(length)d)." +msgstr "確認檔案名稱最多有 %(max)d 字元 (它有 %(length)d)." + +#: taiga/base/api/fields.py:998 +msgid "Please either submit a file or check the clear checkbox, not both." +msgstr "請上傳擋案或是勾選清除方格中二選一" + +#: taiga/base/api/fields.py:1038 +msgid "" +"Upload a valid image. The file you uploaded was either not an image or a " +"corrupted image." +msgstr "上傳有效圖片,你所上傳的檔案非圖檔或已損壞" + +#: taiga/base/api/mixins.py:270 taiga/base/exceptions.py:200 +#: taiga/hooks/api.py:58 taiga/projects/api.py:458 taiga/projects/api.py:495 +#: taiga/projects/api.py:1049 taiga/projects/attachments/api.py:92 +#: taiga/projects/epics/api.py:189 taiga/projects/epics/api.py:273 +#: taiga/projects/issues/api.py:231 taiga/projects/mixins/ordering.py:46 +#: taiga/projects/tasks/api.py:254 taiga/projects/tasks/api.py:296 +#: taiga/projects/userstories/api.py:392 taiga/projects/userstories/api.py:438 +#: taiga/projects/userstories/api.py:478 taiga/webhooks/api.py:60 +msgid "Blocked element" +msgstr "被阻擋的元素" + +#: taiga/base/api/pagination.py:217 +msgid "Page is not 'last', nor can it be converted to an int." +msgstr "頁數不是最後,或者它無法轉成整數 " + +#: taiga/base/api/pagination.py:221 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "無效頁面I (%(page_number)s): %(message)s" + +#: taiga/base/api/permissions.py:54 +msgid "Invalid permission definition." +msgstr "無效的權限定義 " + +#: taiga/base/api/relations.py:236 +#, python-format +msgid "Invalid pk '%s' - object does not exist." +msgstr "無效的pk '%s'- 物件並不存在" + +#: taiga/base/api/relations.py:237 +#, python-format +msgid "Incorrect type. Expected pk value, received %s." +msgstr "不正確類型,預期為pk值,收到%s." + +#: taiga/base/api/relations.py:325 +#, python-format +msgid "Object with %s=%s does not exist." +msgstr " 包含%s=%s物件不存在" + +#: taiga/base/api/relations.py:361 +msgid "Invalid hyperlink - No URL match" +msgstr "無效的超鏈接 - 無相符之網址" + +#: taiga/base/api/relations.py:362 +msgid "Invalid hyperlink - Incorrect URL match" +msgstr "無效的超鏈接 - 不正確的相符網址" + +#: taiga/base/api/relations.py:363 +msgid "Invalid hyperlink due to configuration error" +msgstr "因設定出錯的無效超鏈接" + +#: taiga/base/api/relations.py:364 +msgid "Invalid hyperlink - object does not exist." +msgstr "無效的超鏈接 - 物件並不存在" + +#: taiga/base/api/relations.py:365 +#, python-format +msgid "Incorrect type. Expected url string, received %s." +msgstr "不正確類型,預期為網址格式,收到的是 %s." + +#: taiga/base/api/serializers.py:313 +msgid "Invalid data" +msgstr "無效的資料" + +#: taiga/base/api/serializers.py:405 +msgid "No input provided" +msgstr "無輸入提供" + +#: taiga/base/api/serializers.py:568 +msgid "Cannot create a new item, only existing items may be updated." +msgstr "無法建立新項目,只能更新現有項目" + +#: taiga/base/api/serializers.py:579 +msgid "Expected a list of items." +msgstr "期待的項目清單" + +#: taiga/base/api/views.py:115 +msgid "Not found" +msgstr "找不到" + +#: taiga/base/api/views.py:118 +msgid "Permission denied" +msgstr "許可遭拒絕 " + +#: taiga/base/api/views.py:480 +msgid "Server application error" +msgstr "伺服器應用出錯" + +#: taiga/base/connectors/exceptions.py:15 +msgid "Connection error." +msgstr "連結出錯" + +#: taiga/base/exceptions.py:68 +msgid "Malformed request." +msgstr "遭封鎖" + +#: taiga/base/exceptions.py:73 +msgid "Incorrect authentication credentials." +msgstr "不正確的授權認證 " + +#: taiga/base/exceptions.py:78 +msgid "Authentication credentials were not provided." +msgstr "未担供授權認證 " + +#: taiga/base/exceptions.py:83 +msgid "You do not have permission to perform this action." +msgstr "你無權限進行此動作" + +#: taiga/base/exceptions.py:88 +#, python-format +msgid "Method '%s' not allowed." +msgstr "不允許 '%s' 方式" + +#: taiga/base/exceptions.py:96 +msgid "Could not satisfy the request's Accept header" +msgstr "無法滿藙要求其接受標頭 " + +#: taiga/base/exceptions.py:105 +#, python-format +msgid "Unsupported media type '%s' in request." +msgstr "不支援的資料類型'%s' 被提出" + +#: taiga/base/exceptions.py:113 +msgid "Request was throttled." +msgstr "要求無法執行 " + +#: taiga/base/exceptions.py:114 +#, python-format +msgid "Expected available in %d second%s." +msgstr "預期在 %d 秒%s.內可取得 " + +#: taiga/base/exceptions.py:128 +msgid "Unexpected error" +msgstr "無預期的錯誤" + +#: taiga/base/exceptions.py:140 +msgid "Not found." +msgstr "找不到" + +#: taiga/base/exceptions.py:145 +msgid "Method not supported for this endpoint." +msgstr "從GitHub取得原始碼" + +#: taiga/base/exceptions.py:153 taiga/base/exceptions.py:161 +msgid "Wrong arguments." +msgstr "錯誤的參數" + +#: taiga/base/exceptions.py:165 +msgid "Data validation error" +msgstr "資料有效性錯誤" + +#: taiga/base/exceptions.py:177 +msgid "Integrity Error for wrong or invalid arguments" +msgstr "因錯誤或無效參數,一致性出錯" + +#: taiga/base/exceptions.py:184 +msgid "Precondition error" +msgstr "前提出錯" + +#: taiga/base/exceptions.py:208 +msgid "No room left for more projects." +msgstr "沒有空間再放入專案" + +#: taiga/base/fields.py:77 +#, python-format +msgid "%(value)s is not a list" +msgstr "%(value)s並不是一個清單" + +#: taiga/base/filters.py:94 taiga/base/filters.py:542 +msgid "Error in filter params types." +msgstr "過濾參數類型出錯" + +#: taiga/base/filters.py:150 taiga/base/filters.py:274 +#: taiga/projects/filters.py:55 taiga/projects/userstories/filters.py:47 +msgid "'project' must be an integer value." +msgstr "專案須為整數值" + +#: taiga/base/templates/emails/base-body-html.jinja:14 +msgid "Taiga" +msgstr "Taiga" + +#: taiga/base/templates/emails/hero-body-html.jinja:14 +msgid "You have been Taigatized" +msgstr "您已加入Taigai" + +#: taiga/base/templates/emails/hero-body-html.jinja:306 +#, python-format +msgid "" +"\n" +"

Welcome to " +"%(product_name)s, an Open Source, Agile Project Management Tool

\n" +" " +msgstr "" +"\n" +"

歡迎來到%(product_name)s," +"一個開源、敏捷專案管理工具軟體

\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:15 +#, python-format +msgid "" +"\n" +" Configure email " +"notifications or unsubscribe\n" +"  • \n" +" Taiga Support\n" +"  • \n" +" Contact us\n" +" " +msgstr "" +"\n" +" 設定電子郵件提醒設定或取消訂" +"閱\n" +" • \n" +"Taiga支援\n" +" • \n" +"聯絡我們\n" +" " + +#: taiga/base/templates/emails/includes/footer.jinja:26 +msgid "Follow us on Twitter" +msgstr "透過推特追踪" + +#: taiga/base/templates/emails/includes/footer.jinja:28 +msgid "Get the code on GitHub" +msgstr "從GitHub取得原始碼" + +#: taiga/base/templates/emails/updates-body-html.jinja:14 +msgid "[Taiga] Updates" +msgstr "[Taiga] 更新" + +#: taiga/base/templates/emails/updates-body-html.jinja:368 +msgid "Updates" +msgstr "更新" + +#: taiga/base/templates/emails/updates-body-html.jinja:374 +#, python-format +msgid "" +"\n" +"

comment:" +"

\n" +"

%(comment)s\n" +" " +msgstr "" +"\n" +"

評論:

\n" +"

%(comment)s

" + +#: taiga/base/templates/emails/updates-body-text.jinja:14 +#, python-format +msgid "" +"\n" +" Comment: %(comment)s\n" +" " +msgstr "" +"\n" +"評論: %(comment)s" + +#: taiga/base/utils/urls.py:56 +msgid "Host access error" +msgstr "主機連線錯誤" + +#: taiga/base/utils/urls.py:62 +msgid "IP Address error" +msgstr "IP位址錯誤" + +#: taiga/events/events.py:109 +msgid "User story created" +msgstr "使用者故事已建立" + +#: taiga/events/events.py:112 +msgid "User story changed" +msgstr "使用者故事已修改" + +#: taiga/events/events.py:115 +msgid "User story deleted" +msgstr "使用者故事已刪除" + +#: taiga/events/events.py:117 +msgid "US #{} - {}" +msgstr "US #{} - {}" + +#: taiga/events/events.py:120 +msgid "Task created" +msgstr "工作已建立" + +#: taiga/events/events.py:123 +msgid "Task changed" +msgstr "工作已修改" + +#: taiga/events/events.py:126 +msgid "Task deleted" +msgstr "工作已刪除" + +#: taiga/events/events.py:128 +msgid "Task #{} - {}" +msgstr "工作 #{} - {}" + +#: taiga/events/events.py:131 +msgid "Issue created" +msgstr "" + +#: taiga/events/events.py:134 +msgid "Issue changed" +msgstr "" + +#: taiga/events/events.py:137 +msgid "Issue deleted" +msgstr "" + +#: taiga/events/events.py:139 +msgid "Issue: #{} - {}" +msgstr "" + +#: taiga/events/events.py:142 +msgid "Wiki Page created" +msgstr "" + +#: taiga/events/events.py:145 +msgid "Wiki Page changed" +msgstr "" + +#: taiga/events/events.py:148 +msgid "Wiki Page deleted" +msgstr "" + +#: taiga/events/events.py:150 +msgid "Wiki Page: {}" +msgstr "" + +#: taiga/events/events.py:153 +msgid "Sprint created" +msgstr "" + +#: taiga/events/events.py:156 +msgid "Sprint changed" +msgstr "" + +#: taiga/events/events.py:159 +msgid "Sprint deleted" +msgstr "" + +#: taiga/events/events.py:161 +msgid "Sprint: {}" +msgstr "" + +#: taiga/export_import/api.py:115 +msgid "We needed at least one role" +msgstr "我們至少需要一個角色" + +#: taiga/export_import/api.py:327 +msgid "Needed dump file" +msgstr "需要的堆存檔案" + +#: taiga/export_import/api.py:337 +msgid "Invalid dump format" +msgstr "無效堆存格式" + +#: taiga/export_import/services/store.py:803 +#: taiga/export_import/services/store.py:825 +msgid "error importing project data" +msgstr "滙入重要專案資料出錯" + +#: taiga/export_import/services/store.py:832 +msgid "error importing roles" +msgstr "滙入角色出錯" + +#: taiga/export_import/services/store.py:837 +msgid "error importing memberships" +msgstr "滙入成員資格出錯" + +#: taiga/export_import/services/store.py:852 +msgid "error importing lists of project attributes" +msgstr "滙入標籤出錯" + +#: taiga/export_import/services/store.py:856 +msgid "error importing default project attribute values" +msgstr "" + +#: taiga/export_import/services/store.py:867 +msgid "error importing custom attributes" +msgstr "滙入客制性屬出錯" + +#: taiga/export_import/services/store.py:870 +msgid "error importing sprints" +msgstr "滙入衝刺任務出錯" + +#: taiga/export_import/services/store.py:874 +msgid "error importing issues" +msgstr "滙入問題出錯" + +#: taiga/export_import/services/store.py:878 +msgid "error importing user stories" +msgstr "滙入使用者故事出錯" + +#: taiga/export_import/services/store.py:882 +msgid "error importing epics" +msgstr "" + +#: taiga/export_import/services/store.py:886 +msgid "error importing tasks" +msgstr "滙入任務出錯" + +#: taiga/export_import/services/store.py:893 +msgid "error importing wiki pages" +msgstr "滙入維基頁出錯" + +#: taiga/export_import/services/store.py:897 +msgid "error importing wiki links" +msgstr "滙入維基連結出錯" + +#: taiga/export_import/services/store.py:901 +msgid "error importing tags" +msgstr "滙入標籤出錯" + +#: taiga/export_import/services/store.py:905 +msgid "error importing timelines" +msgstr "滙入時間軸出錯" + +#: taiga/export_import/services/store.py:928 +msgid "unexpected error importing project" +msgstr "匯入專案系統錯誤" + +#: taiga/export_import/services/validations.py:35 +#: taiga/projects/services/projects.py:106 +#: taiga/projects/services/projects.py:156 +#: taiga/projects/services/projects.py:208 +msgid "You can't have more private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:36 +#: taiga/projects/services/projects.py:107 +#: taiga/projects/services/projects.py:157 +#: taiga/projects/services/projects.py:209 +msgid "" +"This project reaches your current limit of memberships for private projects" +msgstr "" + +#: taiga/export_import/services/validations.py:40 +#: taiga/projects/services/projects.py:111 +#: taiga/projects/services/projects.py:161 +#: taiga/projects/services/projects.py:213 +msgid "You can't have more public projects" +msgstr "" + +#: taiga/export_import/services/validations.py:41 +#: taiga/projects/services/projects.py:112 +#: taiga/projects/services/projects.py:162 +#: taiga/projects/services/projects.py:214 +msgid "" +"This project reaches your current limit of memberships for public projects" +msgstr "" + +#: taiga/export_import/tasks.py:51 taiga/export_import/tasks.py:52 +msgid "Error generating project dump" +msgstr "產生專案傾倒時出錯" + +#: taiga/export_import/tasks.py:80 +#, python-brace-format +msgid "" +"\n" +"\n" +"Error loading dump by {user_full_name} <{user_email}>:\"\n" +"\n" +"\n" +"REASON:\n" +"-------\n" +"{reason}\n" +"\n" +"DETAILS:\n" +"--------\n" +"{details}\n" +"\n" +"TRACE ERROR:\n" +"------------" +msgstr "" + +#: taiga/export_import/tasks.py:109 +msgid "Error loading project dump" +msgstr "載入專案傾倒時出錯" + +#: taiga/export_import/tasks.py:110 +msgid "Error loading your project dump file" +msgstr "" + +#: taiga/export_import/tasks.py:125 +msgid " -- no detail info --" +msgstr "-- 無資料 --" + +#: taiga/export_import/templates/emails/dump_project-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump generated

\n" +"

Hello %(user)s,

\n" +"

Your dump from project %(project)s has been correctly generated.\n" +"

You can download it here:

\n" +" Download the dump file\n" +"

This file will be deleted on %(deletion_date)s.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your dump from project %(project)s has been correctly generated. You can " +"download it here:\n" +"\n" +"%(url)s\n" +"\n" +"This file will be deleted on %(deletion_date)s.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/dump_project-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been generated" +msgstr "[%(project)s] 您的專案導入已産生" + +#: taiga/export_import/templates/emails/export_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project %(project)s has not been exported correctly.

\n" +"

The Taiga system administrators have been informed.
Please, try " +"it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/export_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"Your project %(project)s has not been exported correctly.\n" +"\n" +"The Taiga system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/export_error-subject.jinja:9 +#, python-format +msgid "[%(project)s] %(error_subject)s" +msgstr "[%(project)s] %(error_subject)s" + +#: taiga/export_import/templates/emails/import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +"

Error details

\n" +"
%(details)s
\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/import_error-body-text.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"%(error_message)s\n" +"\n" +"Your project has not been imported correctly.\n" +"\n" +"The %(product_name)s system administrators have been informed.\n" +"\n" +"Please, try it again or contact with the support team at %(support_email)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/import_error-subject.jinja:9 +#: taiga/importers/templates/emails/importer_import_error-subject.jinja:9 +#, python-format +msgid "[Taiga] %(error_subject)s" +msgstr "[Taiga] %(error_subject)s" + +#: taiga/export_import/templates/emails/load_dump-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Project dump imported

\n" +"

Hello %(user)s,

\n" +"

Your project dump has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your project dump has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/export_import/templates/emails/load_dump-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your project dump has been imported" +msgstr "[%(project)s] 您堆存的專案已滙入" + +#: taiga/export_import/validators/fields.py:133 +msgid "{}=\"{}\" not found in this project" +msgstr "{}=\"{}\" 無法在此專案中找到" + +#: taiga/export_import/validators/validators.py:173 +#: taiga/projects/custom_attributes/validators.py:97 +msgid "Invalid content. It must be {\"key\": \"value\",...}" +msgstr "無效內容。必須為 {\"key\": \"value\",...}" + +#: taiga/export_import/validators/validators.py:188 +#: taiga/projects/custom_attributes/validators.py:112 +msgid "It contains invalid custom fields." +msgstr "" + +#: taiga/export_import/validators/validators.py:268 +#: taiga/projects/validators.py:43 +msgid "Duplicated name" +msgstr "" + +#: taiga/export_import/validators/validators.py:303 +#, python-format +msgid "" +"An Epic has a related story from an external project (%(project)s) and " +"cannot be imported" +msgstr "" + +#: taiga/external_apps/api.py:32 taiga/external_apps/api.py:59 +#: taiga/external_apps/api.py:71 +msgid "Authentication required" +msgstr "要求取得授權" + +#: taiga/external_apps/models.py:24 +#: taiga/projects/custom_attributes/models.py:25 +#: taiga/projects/milestones/models.py:26 taiga/projects/models.py:164 +#: taiga/projects/models.py:555 taiga/projects/models.py:594 +#: taiga/projects/models.py:638 taiga/projects/models.py:664 +#: taiga/projects/models.py:695 taiga/projects/models.py:733 +#: taiga/projects/models.py:765 taiga/projects/models.py:791 +#: taiga/projects/models.py:817 taiga/projects/models.py:855 +#: taiga/projects/models.py:881 taiga/projects/models.py:919 +#: taiga/projects/models.py:982 taiga/users/admin.py:47 +#: taiga/users/models.py:301 taiga/webhooks/models.py:23 +msgid "name" +msgstr "姓名" + +#: taiga/external_apps/models.py:26 +msgid "Icon url" +msgstr "網址圖標" + +#: taiga/external_apps/models.py:27 +msgid "web" +msgstr "網頁" + +#: taiga/external_apps/models.py:28 taiga/projects/attachments/models.py:67 +#: taiga/projects/custom_attributes/models.py:26 +#: taiga/projects/epics/models.py:56 +#: taiga/projects/history/templatetags/functions.py:14 +#: taiga/projects/issues/models.py:93 taiga/projects/models.py:168 +#: taiga/projects/models.py:986 taiga/projects/tasks/models.py:83 +#: taiga/projects/userstories/models.py:110 +msgid "description" +msgstr "描述" + +#: taiga/external_apps/models.py:30 +msgid "Next url" +msgstr "下一個網址" + +#: taiga/external_apps/models.py:56 +msgid "application" +msgstr "應用程式" + +#: taiga/external_apps/services.py:21 taiga/projects/api.py:424 +#: taiga/projects/api.py:444 +msgid "Invalid token" +msgstr "無效的代碼" + +#: taiga/feedback/models.py:14 taiga/users/models.py:134 +msgid "full name" +msgstr "全名" + +#: taiga/feedback/models.py:16 taiga/users/models.py:126 +msgid "email address" +msgstr "電子郵件" + +#: taiga/feedback/models.py:18 taiga/projects/contact/models.py:30 +msgid "comment" +msgstr "評論" + +#: taiga/feedback/models.py:20 taiga/projects/attachments/models.py:53 +#: taiga/projects/contact/models.py:33 +#: taiga/projects/custom_attributes/models.py:41 +#: taiga/projects/epics/models.py:49 taiga/projects/issues/models.py:85 +#: taiga/projects/likes/models.py:27 taiga/projects/milestones/models.py:49 +#: taiga/projects/models.py:175 taiga/projects/models.py:990 +#: taiga/projects/notifications/models.py:99 taiga/projects/tasks/models.py:69 +#: taiga/projects/userstories/models.py:102 taiga/projects/votes/models.py:48 +#: taiga/projects/wiki/models.py:51 taiga/userstorage/models.py:24 +msgid "created date" +msgstr "創建日期" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Feedback

\n" +"

Taiga has received feedback from %(full_name)s <%(email)s>

\n" +" " +msgstr "" +"\n" +"

回饋

\n" +"

Taiga收到回饋來自 %(full_name)s <%(email)s>

" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:17 +#, python-format +msgid "" +"\n" +"

Comment

\n" +"

%(comment)s

\n" +" " +msgstr "" +"\n" +"

評論

\n" +"

%(comment)s

" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:26 +#: taiga/projects/admin.py:95 +msgid "Extra info" +msgstr "額外資訊" + +#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:38 +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:26 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:9 +#, python-format +msgid "" +"---------\n" +"- From: %(full_name)s <%(email)s>\n" +"---------\n" +"- Comment:\n" +"%(comment)s\n" +"---------" +msgstr "" +"---------\n" +"- 來自: %(full_name)s <%(email)s>\n" +"---------\n" +"- 評論:\n" +"%(comment)s" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:16 +msgid "- Extra info:" +msgstr "-額外資訊:" + +#: taiga/feedback/templates/emails/feedback_notification-body-text.jinja:22 +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:19 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:31 +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:23 +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:26 +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:20 +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:25 +#, python-format +msgid "" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/feedback/templates/emails/feedback_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Feedback from %(full_name)s <%(email)s>\n" +msgstr "" +"\n" +"[Taiga] 回饋來自 %(full_name)s <%(email)s>\n" + +#: taiga/hooks/api.py:43 +msgid "The payload is not valid json" +msgstr "" + +#: taiga/hooks/api.py:52 taiga/projects/epics/api.py:143 +#: taiga/projects/issues/api.py:139 taiga/projects/tasks/api.py:205 +#: taiga/projects/userstories/api.py:338 +msgid "The project doesn't exist" +msgstr "專案不存在" + +#: taiga/hooks/api.py:55 +msgid "Bad signature" +msgstr "錯誤簽名" + +#: taiga/hooks/event_hooks.py:70 +#, python-brace-format +msgid "" +"[{user_name}]({user_url} \"See {user_name}'s {platform} profile\") says in " +"[{platform}#{number}]({comment_url} \"Go to comment\"):\n" +"\n" +"\"{comment_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:75 +#, python-brace-format +msgid "" +"Comment From {platform}:\n" +"\n" +"> {comment_message}" +msgstr "" +"此評論來自{platform}:\n" +"\n" +">{comment_message}" + +#: taiga/hooks/event_hooks.py:88 +msgid "Invalid issue comment information" +msgstr "無效的議題評論資訊" + +#: taiga/hooks/event_hooks.py:134 +#, python-brace-format +msgid "" +"Issue created by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:138 +#, python-brace-format +msgid "Issue created from {platform}." +msgstr "此議題由{platform}建立" + +#: taiga/hooks/event_hooks.py:146 +#, python-brace-format +msgid "" +"Issue modified by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:150 +#, python-brace-format +msgid "Issue modified from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:158 +#, python-brace-format +msgid "" +"Issue closed by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:162 +#, python-brace-format +msgid "Issue closed from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:170 +#, python-brace-format +msgid "" +"Issue reopened by [{user_name}]({user_url} \"See {user_name}'s {platform} " +"profile\") from [{platform}#{number}]({url} \"Go to issue\")." +msgstr "" + +#: taiga/hooks/event_hooks.py:174 +#, python-brace-format +msgid "Issue reopened from {platform}." +msgstr "" + +#: taiga/hooks/event_hooks.py:269 +msgid "Invalid issue information" +msgstr "無效的問題資訊" + +#: taiga/hooks/event_hooks.py:291 taiga/hooks/event_hooks.py:313 +msgid "unknown user" +msgstr "未知的使用者" + +#: taiga/hooks/event_hooks.py:298 +#, python-brace-format +msgid "" +"{user_text} changed the status from [{platform} commit]({commit_url} \"See " +"commit '{commit_id} - {commit_short_message}'\")\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:303 +#, python-brace-format +msgid "" +"Changed status from {platform} commit.\n" +"\n" +" - Status: **{src_status}** → **{dst_status}**" +msgstr "" + +#: taiga/hooks/event_hooks.py:321 +#, python-brace-format +msgid "" +"This {type_name} has been mentioned by {user_text} in the [{platform} commit]" +"({commit_url} \"See commit '{commit_id} - {commit_short_message}'\") " +"\"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:326 +#, python-brace-format +msgid "" +"This issue has been mentioned in the {platform} commit \"{commit_message}\"" +msgstr "" + +#: taiga/hooks/event_hooks.py:348 +msgid "The referenced element doesn't exist" +msgstr "參考元素不存在" + +#: taiga/hooks/event_hooks.py:364 +msgid "The status doesn't exist" +msgstr "狀態不存在" + +#: taiga/importers/asana/api.py:35 taiga/importers/asana/api.py:77 +#: taiga/importers/github/api.py:36 taiga/importers/github/api.py:66 +#: taiga/importers/jira/api.py:52 taiga/importers/jira/api.py:106 +#: taiga/importers/pivotal/api.py:35 taiga/importers/pivotal/api.py:72 +#: taiga/importers/trello/api.py:38 taiga/importers/trello/api.py:75 +msgid "The project param is needed" +msgstr "需要project參數" + +#: taiga/importers/asana/api.py:42 taiga/importers/asana/api.py:65 +#: taiga/importers/asana/api.py:131 +msgid "Invalid Asana API request" +msgstr "無效的Asana API請求" + +#: taiga/importers/asana/api.py:44 taiga/importers/asana/api.py:67 +#: taiga/importers/asana/api.py:133 +msgid "Failed to make the request to Asana API" +msgstr "請求Asana API失敗" + +#: taiga/importers/asana/api.py:121 taiga/importers/github/api.py:115 +msgid "Code param needed" +msgstr "需要code參數" + +#: taiga/importers/asana/tasks.py:31 taiga/importers/asana/tasks.py:32 +msgid "Error importing Asana project" +msgstr "匯入Asana專案失敗" + +#: taiga/importers/github/api.py:127 +msgid "Invalid auth data" +msgstr "無效的授權資料" + +#: taiga/importers/github/api.py:129 +msgid "Third party service failing" +msgstr "第三方的服務出現問題" + +#: taiga/importers/github/tasks.py:31 taiga/importers/github/tasks.py:32 +msgid "Error importing GitHub project" +msgstr "匯入GitHub專案失敗" + +#: taiga/importers/jira/api.py:54 taiga/importers/jira/api.py:89 +#: taiga/importers/jira/api.py:109 taiga/importers/jira/api.py:182 +msgid "The url param is needed" +msgstr "需要url參數" + +#: taiga/importers/jira/api.py:61 +msgid "" +"\n" +" There was an error; probably due to an unsupported Jira " +"version.\n" +" Taiga does not support Jira releases from 8.6." +msgstr "" + +#: taiga/importers/jira/api.py:158 +msgid "Invalid project_type {}" +msgstr "" + +#: taiga/importers/jira/api.py:192 +msgid "Invalid Jira server configuration." +msgstr "" + +#: taiga/importers/jira/api.py:233 taiga/importers/pivotal/api.py:130 +#: taiga/importers/trello/api.py:135 +msgid "Invalid or expired auth token" +msgstr "auth token無效或過期" + +#: taiga/importers/jira/tasks.py:37 taiga/importers/jira/tasks.py:38 +msgid "Error importing Jira project" +msgstr "匯入Jira專案失敗" + +#: taiga/importers/pivotal/tasks.py:31 taiga/importers/pivotal/tasks.py:32 +msgid "Error importing PivotalTracker project" +msgstr "匯入PivotalTracker專案失敗" + +#: taiga/importers/templates/emails/asana_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Asana Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Asana project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Asana project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/asana_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Asana project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

GitHub Project imported

\n" +"

Hello %(user)s,

\n" +"

Your GitHub project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your GitHub project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/github_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your GitHub project has been imported" +msgstr "[%(project)s]您的GitHub專案已匯入" + +#: taiga/importers/templates/emails/importer_import_error-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

%(error_message)s

\n" +"

Hello %(user)s,

\n" +"

Your project has not been imported correctly.

\n" +"

The %(product_name)s system administrators have been informed.
" +"Please, try it again or contact with the support team at\n" +" %(support_email)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Jira Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Jira project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Jira project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/jira_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Jira project has been imported" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Trello Project imported

\n" +"

Hello %(user)s,

\n" +"

Your Trello project has been correctly imported.

\n" +" Go to %(project)s\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(user)s,\n" +"\n" +"Your Trello project has been correctly imported.\n" +"\n" +"You can see the project %(project)s here:\n" +"\n" +"%(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/importers/templates/emails/trello_import_success-subject.jinja:9 +#, python-format +msgid "[%(project)s] Your Trello project has been imported" +msgstr "" + +#: taiga/importers/trello/importer.py:57 +#, python-format +msgid "Invalid Request: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:59 taiga/importers/trello/importer.py:61 +#, python-format +msgid "Unauthorized: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/importer.py:63 taiga/importers/trello/importer.py:65 +#, python-format +msgid "Resource Unavailable: %(text)s at %(url)s" +msgstr "" + +#: taiga/importers/trello/tasks.py:31 taiga/importers/trello/tasks.py:32 +msgid "Error importing Trello project" +msgstr "匯入Trello project失敗" + +#: taiga/permissions/choices.py:11 taiga/permissions/choices.py:22 +msgid "View project" +msgstr "檢視專案" + +#: taiga/permissions/choices.py:12 taiga/permissions/choices.py:24 +msgid "View milestones" +msgstr "檢視里程碑" + +#: taiga/permissions/choices.py:13 taiga/permissions/choices.py:29 +msgid "View epic" +msgstr "" + +#: taiga/permissions/choices.py:14 +msgid "View user stories" +msgstr "檢視使用者故事" + +#: taiga/permissions/choices.py:15 taiga/permissions/choices.py:41 +msgid "View tasks" +msgstr "檢視任務 " + +#: taiga/permissions/choices.py:16 taiga/permissions/choices.py:47 +msgid "View issues" +msgstr "檢視問題 " + +#: taiga/permissions/choices.py:17 taiga/permissions/choices.py:53 +msgid "View wiki pages" +msgstr "檢視維基頁" + +#: taiga/permissions/choices.py:18 taiga/permissions/choices.py:59 +msgid "View wiki links" +msgstr "檢視維基連結" + +#: taiga/permissions/choices.py:25 +msgid "Add milestone" +msgstr "加入里程碑" + +#: taiga/permissions/choices.py:26 +msgid "Modify milestone" +msgstr "修改里程碑" + +#: taiga/permissions/choices.py:27 +msgid "Delete milestone" +msgstr "刪除里程碑 " + +#: taiga/permissions/choices.py:30 +msgid "Add epic" +msgstr "" + +#: taiga/permissions/choices.py:31 +msgid "Modify epic" +msgstr "" + +#: taiga/permissions/choices.py:32 +msgid "Comment epic" +msgstr "" + +#: taiga/permissions/choices.py:33 +msgid "Delete epic" +msgstr "" + +#: taiga/permissions/choices.py:35 +msgid "View user story" +msgstr "檢視使用者故事" + +#: taiga/permissions/choices.py:36 +msgid "Add user story" +msgstr "新增使用者故事" + +#: taiga/permissions/choices.py:37 +msgid "Modify user story" +msgstr "修改使用者故事" + +#: taiga/permissions/choices.py:38 +msgid "Comment user story" +msgstr "" + +#: taiga/permissions/choices.py:39 +msgid "Delete user story" +msgstr "刪除使用者故事" + +#: taiga/permissions/choices.py:42 +msgid "Add task" +msgstr "新增任務 " + +#: taiga/permissions/choices.py:43 +msgid "Modify task" +msgstr "修改任務 " + +#: taiga/permissions/choices.py:44 +msgid "Comment task" +msgstr "" + +#: taiga/permissions/choices.py:45 +msgid "Delete task" +msgstr "刪除任務 " + +#: taiga/permissions/choices.py:48 +msgid "Add issue" +msgstr "新增問題 " + +#: taiga/permissions/choices.py:49 +msgid "Modify issue" +msgstr "修改問題" + +#: taiga/permissions/choices.py:50 +msgid "Comment issue" +msgstr "" + +#: taiga/permissions/choices.py:51 +msgid "Delete issue" +msgstr "刪除問題 " + +#: taiga/permissions/choices.py:54 +msgid "Add wiki page" +msgstr "新增維基頁" + +#: taiga/permissions/choices.py:55 +msgid "Modify wiki page" +msgstr "修改維基頁" + +#: taiga/permissions/choices.py:56 +msgid "Comment wiki page" +msgstr "" + +#: taiga/permissions/choices.py:57 +msgid "Delete wiki page" +msgstr "刪除維基頁 " + +#: taiga/permissions/choices.py:60 +msgid "Add wiki link" +msgstr "新增維基連結" + +#: taiga/permissions/choices.py:61 +msgid "Modify wiki link" +msgstr "修改維基連結" + +#: taiga/permissions/choices.py:62 +msgid "Delete wiki link" +msgstr "刪除維基連結" + +#: taiga/permissions/choices.py:66 +msgid "Modify project" +msgstr "修改專案" + +#: taiga/permissions/choices.py:67 +msgid "Delete project" +msgstr "刪除專案" + +#: taiga/permissions/choices.py:68 +msgid "Add member" +msgstr "新增成員" + +#: taiga/permissions/choices.py:69 +msgid "Remove member" +msgstr "移除成員" + +#: taiga/permissions/choices.py:70 +msgid "Admin project values" +msgstr "管理員專案數值" + +#: taiga/permissions/choices.py:71 +msgid "Admin roles" +msgstr "管理員角色" + +#: taiga/projects/admin.py:89 +msgid "Privacy" +msgstr "" + +#: taiga/projects/admin.py:100 +msgid "Modules" +msgstr "模組" + +#: taiga/projects/admin.py:108 +msgid "Default values" +msgstr "預設值" + +#: taiga/projects/admin.py:115 +msgid "Activity" +msgstr "" + +#: taiga/projects/admin.py:120 +msgid "Fans" +msgstr "" + +#: taiga/projects/admin.py:128 taiga/projects/attachments/models.py:31 +#: taiga/projects/epics/models.py:39 taiga/projects/issues/models.py:32 +#: taiga/projects/milestones/models.py:35 taiga/projects/models.py:184 +#: taiga/projects/notifications/models.py:57 taiga/projects/tasks/models.py:40 +#: taiga/projects/userstories/models.py:84 taiga/projects/wiki/models.py:39 +#: taiga/users/admin.py:66 taiga/userstorage/models.py:20 +msgid "owner" +msgstr "所有者" + +#: taiga/projects/admin.py:169 +msgid "Make public" +msgstr "" + +#: taiga/projects/admin.py:185 +#, python-brace-format +msgid "{count} successfully made public." +msgstr "" + +#: taiga/projects/admin.py:188 +msgid "Make private" +msgstr "" + +#: taiga/projects/admin.py:202 +#, python-brace-format +msgid "{count} successfully made private." +msgstr "" + +#: taiga/projects/api.py:172 taiga/users/api.py:235 +msgid "Incomplete arguments" +msgstr "不完整參數" + +#: taiga/projects/api.py:176 taiga/users/api.py:240 +msgid "Invalid image format" +msgstr "無效的圖片檔案" + +#: taiga/projects/api.py:237 +msgid "Invalid template name" +msgstr "" + +#: taiga/projects/api.py:240 +msgid "Invalid template description" +msgstr "" + +#: taiga/projects/api.py:404 taiga/projects/api.py:1093 +msgid "Invalid user id" +msgstr "" + +#: taiga/projects/api.py:410 taiga/projects/api.py:1099 +msgid "The user doesn't exist" +msgstr "" + +#: taiga/projects/api.py:414 +msgid "The user must already be a project member" +msgstr "" + +#: taiga/projects/api.py:678 +msgid "The default swimlane cannot be deleted." +msgstr "" + +#: taiga/projects/api.py:708 +msgid "You can't delete the default due date status of a user story" +msgstr "" + +#: taiga/projects/api.py:724 +msgid "Project does already have due dates" +msgstr "" + +#: taiga/projects/api.py:784 +msgid "You can't delete the default due date status of a task" +msgstr "" + +#: taiga/projects/api.py:800 +msgid "Project does already have task due dates" +msgstr "" + +#: taiga/projects/api.py:925 +msgid "You can't delete the default due date status of an issue" +msgstr "" + +#: taiga/projects/api.py:941 +msgid "Project does already have issue due dates" +msgstr "" + +#: taiga/projects/api.py:1053 taiga/projects/validators.py:230 +msgid "" +"To add members to a project, first you have to verify your email address" +msgstr "" + +#: taiga/projects/api.py:1111 +msgid "" +"This user can't be removed from the following projects, because that would " +"leave them without any active admin: {}." +msgstr "" + +#: taiga/projects/api.py:1125 +#, fuzzy +#| msgid "This field is required." +msgid "Email is required" +msgstr "此欄位是必要的。" + +#: taiga/projects/api.py:1136 +msgid "" +"The project must have an owner and at least one of the users must be an " +"active admin" +msgstr "" + +#: taiga/projects/api.py:1171 +msgid "You don't have permissions to see that." +msgstr "" + +#: taiga/projects/attachments/api.py:46 +msgid "Partial updates are not supported" +msgstr "不支援部份更新" + +#: taiga/projects/attachments/api.py:61 +msgid "Object id issue doesn't exist" +msgstr "" + +#: taiga/projects/attachments/api.py:64 +msgid "Project ID does not match between object and project" +msgstr "" + +#: taiga/projects/attachments/models.py:39 taiga/projects/contact/models.py:26 +#: taiga/projects/custom_attributes/models.py:36 +#: taiga/projects/epics/models.py:31 taiga/projects/issues/models.py:81 +#: taiga/projects/milestones/models.py:43 taiga/projects/models.py:541 +#: taiga/projects/models.py:569 taiga/projects/models.py:612 +#: taiga/projects/models.py:648 taiga/projects/models.py:678 +#: taiga/projects/models.py:709 taiga/projects/models.py:747 +#: taiga/projects/models.py:775 taiga/projects/models.py:801 +#: taiga/projects/models.py:831 taiga/projects/models.py:865 +#: taiga/projects/models.py:895 taiga/projects/models.py:915 +#: taiga/projects/notifications/models.py:75 +#: taiga/projects/notifications/models.py:104 taiga/projects/tasks/models.py:56 +#: taiga/projects/userstories/models.py:80 taiga/projects/wiki/models.py:27 +#: taiga/projects/wiki/models.py:80 taiga/users/models.py:316 +msgid "project" +msgstr "專案" + +#: taiga/projects/attachments/models.py:46 +msgid "content type" +msgstr "內容類型" + +#: taiga/projects/attachments/models.py:50 +msgid "object id" +msgstr "物件ID" + +#: taiga/projects/attachments/models.py:56 +#: taiga/projects/custom_attributes/models.py:43 +#: taiga/projects/epics/models.py:52 taiga/projects/issues/models.py:88 +#: taiga/projects/milestones/models.py:52 taiga/projects/models.py:178 +#: taiga/projects/models.py:993 taiga/projects/tasks/models.py:72 +#: taiga/projects/userstories/models.py:105 taiga/projects/wiki/models.py:54 +#: taiga/userstorage/models.py:26 +msgid "modified date" +msgstr "修改日期" + +#: taiga/projects/attachments/models.py:61 +msgid "attached file" +msgstr "附加檔案" + +#: taiga/projects/attachments/models.py:63 +msgid "sha1" +msgstr "sha1" + +#: taiga/projects/attachments/models.py:65 +msgid "is deprecated" +msgstr "棄用" + +#: taiga/projects/attachments/models.py:66 +msgid "from comment" +msgstr "" + +#: taiga/projects/attachments/models.py:68 +#: taiga/projects/custom_attributes/models.py:30 +#: taiga/projects/epics/models.py:110 taiga/projects/milestones/models.py:58 +#: taiga/projects/models.py:559 taiga/projects/models.py:598 +#: taiga/projects/models.py:640 taiga/projects/models.py:666 +#: taiga/projects/models.py:699 taiga/projects/models.py:735 +#: taiga/projects/models.py:767 taiga/projects/models.py:793 +#: taiga/projects/models.py:821 taiga/projects/models.py:857 +#: taiga/projects/models.py:883 taiga/projects/models.py:921 +#: taiga/projects/wiki/models.py:87 taiga/users/models.py:307 +msgid "order" +msgstr "次序" + +#: taiga/projects/attachments/validators.py:46 +msgid "" +"Invalid attachment id to move after. The attachment must belong to the same " +"item (epic, userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/attachments/validators.py:61 +msgid "" +"Invalid attachment ids. All attachments must belong to the same item (epic, " +"userstory, task, issue or wiki page)." +msgstr "" + +#: taiga/projects/choices.py:12 +msgid "Whereby.com" +msgstr "" + +#: taiga/projects/choices.py:13 +msgid "Jitsi" +msgstr "Jitsi" + +#: taiga/projects/choices.py:14 +msgid "Custom" +msgstr "自定" + +#: taiga/projects/choices.py:15 +msgid "Talky" +msgstr "Talky" + +#: taiga/projects/choices.py:24 +msgid "This project is blocked due to payment failure" +msgstr "" + +#: taiga/projects/choices.py:25 +msgid "This project is blocked by admin staff" +msgstr "" + +#: taiga/projects/choices.py:26 +msgid "This project is blocked because the owner left" +msgstr "" + +#: taiga/projects/choices.py:27 +msgid "This project is blocked while it's deleted" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:18 +#, python-format +msgid "" +"\n" +" %(full_name)s has " +"written to %(project_name)s\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-html.jinja:28 +#, python-format +msgid "" +"\n" +" You are receiving this message because you are listed as " +"administrator of the project titled %(project_name)s. If you don't want " +"members of the Taiga community contacting your project, please update your project settings to " +"prevent such contacts. Regular communications amongst members of the project " +"will not be affected.\n" +" " +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"%(full_name)s has written to %(project_name)s\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-body-text.jinja:15 +#, python-format +msgid "" +"\n" +"You are receiving this message because you are listed as administrator of " +"the project titled %(project_name)s. If you don't want members of the Taiga " +"community contacting your project, please update your project settings in " +"%(project_settings_url)s to prevent such contacts. Regular communications " +"amongst members of the project will not be affected.\n" +msgstr "" + +#: taiga/projects/contact/templates/emails/contact_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] %(full_name)s has sent a message to the project %(project_name)s\n" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:21 +msgid "Text" +msgstr "單行文字" + +#: taiga/projects/custom_attributes/choices.py:22 +msgid "Multi-Line Text" +msgstr "多行列文字" + +#: taiga/projects/custom_attributes/choices.py:23 +msgid "Rich text" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:24 +msgid "Date" +msgstr "日期" + +#: taiga/projects/custom_attributes/choices.py:25 +msgid "Url" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:26 +msgid "Dropdown" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:27 +msgid "Checkbox" +msgstr "" + +#: taiga/projects/custom_attributes/choices.py:28 +msgid "Number" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:29 +#: taiga/projects/issues/models.py:64 +msgid "type" +msgstr "類型" + +#: taiga/projects/custom_attributes/models.py:90 +msgid "values" +msgstr "價值" + +#: taiga/projects/custom_attributes/models.py:103 +msgid "epic" +msgstr "" + +#: taiga/projects/custom_attributes/models.py:124 +#: taiga/projects/tasks/models.py:29 taiga/projects/userstories/models.py:31 +msgid "user story" +msgstr "使用者故事" + +#: taiga/projects/custom_attributes/models.py:145 +msgid "task" +msgstr "任務 " + +#: taiga/projects/custom_attributes/models.py:166 +msgid "issue" +msgstr "問題 " + +#: taiga/projects/custom_attributes/validators.py:46 +msgid "Already exists one with the same name." +msgstr "已存在相同姓名" + +#: taiga/projects/due_dates/models.py:14 +msgid "due date" +msgstr "" + +#: taiga/projects/due_dates/models.py:17 +msgid "reason for the due date" +msgstr "" + +#: taiga/projects/epics/api.py:83 +msgid "You don't have permissions to set this status to this epic." +msgstr "" + +#: taiga/projects/epics/models.py:25 taiga/projects/issues/models.py:25 +#: taiga/projects/tasks/models.py:33 taiga/projects/userstories/models.py:71 +msgid "ref" +msgstr "ref" + +#: taiga/projects/epics/models.py:43 taiga/projects/issues/models.py:40 +#: taiga/projects/models.py:952 taiga/projects/tasks/models.py:48 +#: taiga/projects/userstories/models.py:87 +msgid "status" +msgstr "狀態" + +#: taiga/projects/epics/models.py:46 +msgid "epics order" +msgstr "" + +#: taiga/projects/epics/models.py:55 taiga/projects/issues/models.py:92 +#: taiga/projects/tasks/models.py:76 taiga/projects/userstories/models.py:109 +msgid "subject" +msgstr "主旨" + +#: taiga/projects/epics/models.py:59 taiga/projects/models.py:563 +#: taiga/projects/models.py:604 taiga/projects/models.py:670 +#: taiga/projects/models.py:703 taiga/projects/models.py:739 +#: taiga/projects/models.py:769 taiga/projects/models.py:795 +#: taiga/projects/models.py:825 taiga/projects/models.py:859 +#: taiga/projects/models.py:887 taiga/users/models.py:136 +msgid "color" +msgstr "顏色" + +#: taiga/projects/epics/models.py:66 taiga/projects/issues/models.py:100 +#: taiga/projects/tasks/models.py:90 taiga/projects/userstories/models.py:117 +msgid "assigned to" +msgstr "指派給" + +#: taiga/projects/epics/models.py:70 taiga/projects/userstories/models.py:124 +msgid "is client requirement" +msgstr "客戶要求" + +#: taiga/projects/epics/models.py:72 taiga/projects/userstories/models.py:126 +msgid "is team requirement" +msgstr "團隊要求" + +#: taiga/projects/epics/models.py:76 +msgid "user stories" +msgstr "" + +#: taiga/projects/epics/models.py:78 taiga/projects/issues/models.py:105 +#: taiga/projects/tasks/models.py:97 taiga/projects/userstories/models.py:138 +msgid "external reference" +msgstr "外部參考" + +#: taiga/projects/epics/validators.py:26 +msgid "There's no epic with that id" +msgstr "" + +#: taiga/projects/history/api.py:89 +msgid "comment is required" +msgstr "" + +#: taiga/projects/history/api.py:92 +msgid "deleted comments can't be edited" +msgstr "" + +#: taiga/projects/history/api.py:135 +msgid "Comment already deleted" +msgstr "評論已刪除 " + +#: taiga/projects/history/api.py:156 +msgid "Comment not deleted" +msgstr "不可刪除 之評論 " + +#: taiga/projects/history/choices.py:19 +msgid "Change" +msgstr "更改" + +#: taiga/projects/history/choices.py:20 +msgid "Create" +msgstr "創建" + +#: taiga/projects/history/choices.py:21 +msgid "Delete" +msgstr "刪除 " + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:33 +#, python-format +msgid "%(role)s role points" +msgstr "%(role)s 角色點數" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:36 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:141 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:144 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:168 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:171 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:195 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:198 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:221 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:275 +msgid "from" +msgstr "來自" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:42 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:152 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:155 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:179 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:182 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:206 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:209 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:227 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:252 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:281 +msgid "to" +msgstr "給" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:54 +msgid "Added new attachment" +msgstr "新增附件" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:72 +msgid "Updated attachment" +msgstr "更新附件" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:78 +msgid "deprecated" +msgstr "棄用" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:80 +msgid "not deprecated" +msgstr "不棄用" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:96 +msgid "Deleted attachment" +msgstr "刪除附件" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:115 +msgid "added" +msgstr "新增" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:120 +msgid "removed" +msgstr "移除 " + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:145 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:156 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:199 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:210 +#: taiga/projects/services/stats.py:44 taiga/projects/services/stats.py:45 +msgid "Unassigned" +msgstr "無指定" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:172 +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:183 +msgid "Not set" +msgstr "" + +#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:294 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:99 +msgid "-deleted-" +msgstr "-刪除-" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "to:" +msgstr "給:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:31 +msgid "from:" +msgstr "from:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:37 +msgid "Added" +msgstr "新增的" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:44 +msgid "Changed" +msgstr "改變的" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:51 +msgid "Deleted" +msgstr "刪除的:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:65 +msgid "added:" +msgstr "新增的:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:68 +msgid "removed:" +msgstr "移除的:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:73 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91 +msgid "From:" +msgstr "來自:" + +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:74 +#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:92 +msgid "To:" +msgstr "給:" + +#: taiga/projects/history/templatetags/functions.py:15 +#: taiga/projects/wiki/models.py:33 +msgid "content" +msgstr "內容" + +#: taiga/projects/history/templatetags/functions.py:16 +#: taiga/projects/mixins/blocked.py:21 +msgid "blocked note" +msgstr "封鎖筆記" + +#: taiga/projects/history/templatetags/functions.py:17 +msgid "sprint" +msgstr "衝刺任務" + +#: taiga/projects/issues/api.py:161 +msgid "You don't have permissions to set this sprint to this issue." +msgstr "您無權限設定此問題的衝刺任務" + +#: taiga/projects/issues/api.py:165 +msgid "You don't have permissions to set this status to this issue." +msgstr "您無權限設定此問題的狀態" + +#: taiga/projects/issues/api.py:169 +msgid "You don't have permissions to set this severity to this issue." +msgstr "您無權限設定此問題的嚴重性" + +#: taiga/projects/issues/api.py:173 +msgid "You don't have permissions to set this priority to this issue." +msgstr "您無權限設定此問題的優先性" + +#: taiga/projects/issues/api.py:177 +msgid "You don't have permissions to set this type to this issue." +msgstr "您無權限設定此問題的類型" + +#: taiga/projects/issues/models.py:48 +msgid "severity" +msgstr "嚴重性" + +#: taiga/projects/issues/models.py:56 +msgid "priority" +msgstr "優先性" + +#: taiga/projects/issues/models.py:73 taiga/projects/tasks/models.py:66 +#: taiga/projects/userstories/models.py:74 +msgid "milestone" +msgstr "里程碑" + +#: taiga/projects/issues/models.py:90 taiga/projects/tasks/models.py:74 +msgid "finished date" +msgstr "完成日期" + +#: taiga/projects/issues/validators.py:59 +#: taiga/projects/tasks/validators.py:165 +#: taiga/projects/userstories/validators.py:275 +msgid "The milestone isn't valid for the project" +msgstr "" + +#: taiga/projects/issues/validators.py:69 +msgid "All the issues must be from the same project" +msgstr "" + +#: taiga/projects/likes/models.py:30 +msgid "Like" +msgstr "喜歡" + +#: taiga/projects/likes/models.py:31 +msgid "Likes" +msgstr "喜歡" + +#: taiga/projects/milestones/models.py:29 taiga/projects/models.py:166 +#: taiga/projects/models.py:557 taiga/projects/models.py:596 +#: taiga/projects/models.py:697 taiga/projects/models.py:819 +#: taiga/projects/models.py:984 taiga/projects/wiki/models.py:31 +#: taiga/users/admin.py:53 taiga/users/models.py:303 +msgid "slug" +msgstr "代稱" + +#: taiga/projects/milestones/models.py:46 +msgid "estimated start date" +msgstr "预計開始日期" + +#: taiga/projects/milestones/models.py:47 +msgid "estimated finish date" +msgstr "預計完成日期" + +#: taiga/projects/milestones/models.py:54 taiga/projects/models.py:561 +#: taiga/projects/models.py:600 taiga/projects/models.py:701 +#: taiga/projects/models.py:823 +msgid "is closed" +msgstr "被關閉" + +#: taiga/projects/milestones/models.py:56 +msgid "disponibility" +msgstr "disponibility" + +#: taiga/projects/milestones/models.py:77 +msgid "The estimated start must be previous to the estimated finish." +msgstr "預估開始必須在預估結束之前" + +#: taiga/projects/milestones/validators.py:24 +msgid "There's no milestone with that id" +msgstr "" + +#: taiga/projects/milestones/validators.py:55 +#: taiga/projects/userstories/validators.py:285 +msgid "All the user stories must be from the same project" +msgstr "" + +#: taiga/projects/mixins/blocked.py:19 +msgid "is blocked" +msgstr "已封鎖" + +#: taiga/projects/mixins/by_ref.py:21 +msgid "ref param is needed" +msgstr "" + +#: taiga/projects/mixins/by_ref.py:24 +msgid "project or project__slug param is needed" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:32 +msgid "Query param 'moveTo' is required" +msgstr "" + +#: taiga/projects/mixins/on_destroy.py:84 +msgid "Cannot set swimlane to None if there are available swimlanes" +msgstr "" + +#: taiga/projects/mixins/ordering.py:36 +#, python-brace-format +msgid "'{param}' parameter is mandatory" +msgstr "'{param}' 參數為必要" + +#: taiga/projects/mixins/ordering.py:40 +msgid "'project' parameter is mandatory" +msgstr "'project'參數為必要" + +#: taiga/projects/mixins/validators.py:29 +msgid "The user must be a project member." +msgstr "" + +#: taiga/projects/models.py:86 +msgid "email" +msgstr "電子郵件" + +#: taiga/projects/models.py:88 +msgid "create at" +msgstr "創建於" + +#: taiga/projects/models.py:90 taiga/users/models.py:154 +msgid "token" +msgstr "代號" + +#: taiga/projects/models.py:101 +msgid "invitation extra text" +msgstr "額外文案邀請" + +#: taiga/projects/models.py:104 taiga/projects/models.py:988 +msgid "user order" +msgstr "使用者次序" + +#: taiga/projects/models.py:120 +msgid "The user is already member of the project" +msgstr "使用者已是專案成員" + +#: taiga/projects/models.py:127 +msgid "default epic status" +msgstr "" + +#: taiga/projects/models.py:131 +msgid "default US status" +msgstr "預設使用者故事狀態" + +#: taiga/projects/models.py:134 +msgid "default points" +msgstr "預設點數" + +#: taiga/projects/models.py:138 +msgid "default task status" +msgstr "預設任務狀態" + +#: taiga/projects/models.py:141 +msgid "default priority" +msgstr "預設優先性" + +#: taiga/projects/models.py:144 +msgid "default severity" +msgstr "預設嚴重性" + +#: taiga/projects/models.py:148 +msgid "default issue status" +msgstr "預設問題狀態" + +#: taiga/projects/models.py:152 +msgid "default issue type" +msgstr "預設議題類型" + +#: taiga/projects/models.py:156 +msgid "default swimlane" +msgstr "" + +#: taiga/projects/models.py:172 +msgid "logo" +msgstr "圖標" + +#: taiga/projects/models.py:189 +msgid "members" +msgstr "成員" + +#: taiga/projects/models.py:192 +msgid "total of milestones" +msgstr "全部里程碑" + +#: taiga/projects/models.py:193 +msgid "total story points" +msgstr "全部故事點數" + +#: taiga/projects/models.py:195 taiga/projects/models.py:998 +msgid "active contact" +msgstr "" + +#: taiga/projects/models.py:197 taiga/projects/models.py:1000 +msgid "active epics panel" +msgstr "" + +#: taiga/projects/models.py:199 taiga/projects/models.py:1002 +msgid "active backlog panel" +msgstr "活躍的待辦任務優先表面板" + +#: taiga/projects/models.py:201 taiga/projects/models.py:1004 +msgid "active kanban panel" +msgstr "活躍的看板式面板" + +#: taiga/projects/models.py:203 taiga/projects/models.py:1006 +msgid "active wiki panel" +msgstr "活躍的維基面板" + +#: taiga/projects/models.py:205 taiga/projects/models.py:1008 +msgid "active issues panel" +msgstr "活躍的問題面板" + +#: taiga/projects/models.py:208 taiga/projects/models.py:1015 +msgid "videoconference system" +msgstr "視訊會議系統" + +#: taiga/projects/models.py:210 taiga/projects/models.py:1017 +msgid "videoconference extra data" +msgstr "視訊會議額外資料" + +#: taiga/projects/models.py:219 +msgid "creation template" +msgstr "創建模版" + +#: taiga/projects/models.py:222 taiga/users/admin.py:59 +msgid "is private" +msgstr "私密" + +#: taiga/projects/models.py:224 +msgid "anonymous permissions" +msgstr "匿名權限" + +#: taiga/projects/models.py:226 +msgid "user permissions" +msgstr "使用者權限" + +#: taiga/projects/models.py:229 +msgid "is featured" +msgstr " 受矚目的" + +#: taiga/projects/models.py:232 taiga/projects/models.py:1010 +msgid "is looking for people" +msgstr "正在找人" + +#: taiga/projects/models.py:234 taiga/projects/models.py:1012 +msgid "looking for people note" +msgstr "" + +#: taiga/projects/models.py:248 +msgid "project transfer token" +msgstr "" + +#: taiga/projects/models.py:252 +msgid "blocked code" +msgstr "" + +#: taiga/projects/models.py:255 taiga/projects/notifications/models.py:64 +msgid "updated date time" +msgstr "更新日期時間" + +#: taiga/projects/models.py:258 taiga/projects/models.py:270 +#: taiga/projects/votes/models.py:18 +msgid "count" +msgstr "數量" + +#: taiga/projects/models.py:261 +msgid "fans last week" +msgstr "上週粉絲" + +#: taiga/projects/models.py:264 +msgid "fans last month" +msgstr "上個月粉絲" + +#: taiga/projects/models.py:267 +msgid "fans last year" +msgstr "去年粉絲" + +#: taiga/projects/models.py:274 +msgid "activity last week" +msgstr "上週活躍成員" + +#: taiga/projects/models.py:278 +msgid "activity last month" +msgstr "上月活躍成員" + +#: taiga/projects/models.py:282 +msgid "activity last year" +msgstr "去年活躍成員" + +#: taiga/projects/models.py:544 +msgid "modules config" +msgstr "模組設定" + +#: taiga/projects/models.py:602 +msgid "is archived" +msgstr "已歸檔" + +#: taiga/projects/models.py:606 taiga/projects/models.py:937 +msgid "work in progress limit" +msgstr "工作進度限制" + +#: taiga/projects/models.py:642 taiga/userstorage/models.py:28 +msgid "value" +msgstr "價值" + +#: taiga/projects/models.py:668 taiga/projects/models.py:737 +#: taiga/projects/models.py:885 +msgid "by default" +msgstr "" + +#: taiga/projects/models.py:672 taiga/projects/models.py:741 +#: taiga/projects/models.py:889 +msgid "days to due" +msgstr "" + +#: taiga/projects/models.py:944 +msgid "user story status" +msgstr "" + +#: taiga/projects/models.py:996 +msgid "default owner's role" +msgstr "預設所有者角色" + +#: taiga/projects/models.py:1019 +msgid "default options" +msgstr "預設選項" + +#: taiga/projects/models.py:1020 +msgid "epic statuses" +msgstr "" + +#: taiga/projects/models.py:1021 +msgid "us statuses" +msgstr "我們狀況" + +#: taiga/projects/models.py:1022 +msgid "us duedates" +msgstr "" + +#: taiga/projects/models.py:1023 taiga/projects/userstories/models.py:47 +#: taiga/projects/userstories/models.py:92 +msgid "points" +msgstr "點數" + +#: taiga/projects/models.py:1024 +msgid "task statuses" +msgstr "任務狀況" + +#: taiga/projects/models.py:1025 +msgid "task duedates" +msgstr "" + +#: taiga/projects/models.py:1026 +msgid "issue statuses" +msgstr "問題狀況" + +#: taiga/projects/models.py:1027 +msgid "issue types" +msgstr "問題類型" + +#: taiga/projects/models.py:1028 +msgid "issue duedates" +msgstr "" + +#: taiga/projects/models.py:1029 +msgid "priorities" +msgstr "優先性" + +#: taiga/projects/models.py:1030 +msgid "severities" +msgstr "嚴重性" + +#: taiga/projects/models.py:1031 +msgid "roles" +msgstr "角色" + +#: taiga/projects/models.py:1032 +msgid "epic custom attributes" +msgstr "" + +#: taiga/projects/models.py:1033 +msgid "us custom attributes" +msgstr "" + +#: taiga/projects/models.py:1034 +msgid "task custom attributes" +msgstr "" + +#: taiga/projects/models.py:1035 +msgid "issue custom attributes" +msgstr "" + +#: taiga/projects/notifications/choices.py:19 +msgid "Involved" +msgstr "相關涉入者" + +#: taiga/projects/notifications/choices.py:20 +msgid "All" +msgstr "所有" + +#: taiga/projects/notifications/choices.py:21 +msgid "None" +msgstr "無" + +#: taiga/projects/notifications/choices.py:35 +msgid "Assigned" +msgstr "" + +#: taiga/projects/notifications/choices.py:36 +msgid "Mentioned" +msgstr "" + +#: taiga/projects/notifications/choices.py:37 +msgid "Added as watcher" +msgstr "" + +#: taiga/projects/notifications/choices.py:38 +msgid "Added as member" +msgstr "" + +#: taiga/projects/notifications/choices.py:39 +msgid "Comment" +msgstr "" + +#: taiga/projects/notifications/choices.py:40 +msgid "Mentioned in comment" +msgstr "" + +#: taiga/projects/notifications/models.py:62 +msgid "created date time" +msgstr "創建日期時間" + +#: taiga/projects/notifications/models.py:66 +msgid "history entries" +msgstr "歷史輸入" + +#: taiga/projects/notifications/models.py:69 +msgid "notify users" +msgstr "通知用戶" + +#: taiga/projects/notifications/models.py:109 +#: taiga/projects/notifications/models.py:110 +msgid "Watched" +msgstr "已觀注" + +#: taiga/projects/notifications/services.py:70 +#: taiga/projects/notifications/services.py:94 +#: taiga/projects/settings/services.py:38 +#: taiga/projects/settings/services.py:56 +msgid "Notify exists for specified user and project" +msgstr "通知特定使用者與專案退出" + +#: taiga/projects/notifications/services.py:531 +msgid "Invalid value for notify level" +msgstr "通知水平的無效值" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic updated

\n" +"

Hello %(user)s,
%(changer)s has updated a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Epic updated\n" +"Hello %(user)s, %(changer)s has updated a epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New epic created

\n" +"

Hello %(user)s,
%(changer)s has created a new epic on " +"%(project)s

\n" +"

Epic #%(ref)s %(subject)s

\n" +" See epic\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New epic created\n" +"Hello %(user)s, %(changer)s has created a new epic on %(project)s\n" +"See epic #%(ref)s %(subject)s at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Epic deleted

\n" +"

Hello %(user)s,
%(changer)s has deleted a epic on %(project)s\n" +"

Epic #%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Epic deleted\n" +"Hello %(user)s, %(changer)s has deleted a epic on %(project)s\n" +"Epic #%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the epic #%(ref)s \"%(subject)s\"\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated an issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Issue updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新問題 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New issue created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new issue:

\n" +"

#%(ref)s %(subject)s

\n" +" See issue\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New issue created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See issue at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 提出問題 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Issue deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an issue:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Issue deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted an issue:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the issue #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 刪除問題 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a sprint:

\n" +"

%(name)s

\n" +" See " +"sprint\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Sprint updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新衝刺任務 \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New sprint created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new sprint

\n" +"

%(name)s

\n" +" See " +"sprint\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New sprint created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new sprint:\n" +"\n" +"%(name)s\n" +"\n" +"See sprint at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s]創建衝刺任務 \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Sprint deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted an sprint:

\n" +"

%(name)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Sprint deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a sprint:\n" +"\n" +"%(name)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n" +msgstr "" +"\n" +"[%(project)s] 刪除衝刺任務 \"%(milestone)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Task updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 己上傳任務 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New task created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new task:

\n" +"

#%(ref)s %(subject)s

\n" +" See task\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New task created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See task at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 創建任務 #%(ref)s \"%(subject)s\"\n" +"\n" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Task deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a task:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Task deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a task:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 刪除任務 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"User story updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新使用者故事 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New user story created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new user story:

\n" +"

#%(ref)s %(subject)s

\n" +" See user story\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New user story created on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new user story:\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"See user story at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 創建使用者故事 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

User Story deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a user story:

\n" +"

#%(ref)s %(subject)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"User Story deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a user story\n" +"\n" +"#%(ref)s %(subject)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the US #%(ref)s \"%(subject)s\"\n" +msgstr "" +"\n" +"[%(project)s] 刪除使用者故事 #%(ref)s \"%(subject)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki Page updated on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has updated a wiki page:

\n" +"

%(page)s

\n" +" See Wiki " +"Page\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja:11 +#, python-format +msgid "" +"\n" +"Wiki Page updated on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has updated a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Updated the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] 更新維基頁 \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

New wiki page created on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has created a new wiki page:

\n" +"

%(page)s

\n" +" See " +"wiki page\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"New wiki page created page on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has created a new wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"See wiki page at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Created the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] 創建維基頁 \"%(page)s\"\n" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Wiki page deleted on %(project)s

\n" +"

Hello %(user)s,
%(changer)s has deleted a wiki page:

\n" +"

%(page)s

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Wiki page deleted on %(project)s\n" +"\n" +"Hello %(user)s, %(changer)s has deleted a wiki page:\n" +"\n" +"%(page)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Deleted the Wiki Page \"%(page)s\"\n" +msgstr "" +"\n" +"[%(project)s] 刪除維基頁 \"%(page)s\"\n" + +#: taiga/projects/notifications/validators.py:37 +msgid "Watchers contains invalid users" +msgstr "監督者包含無效使用者" + +#: taiga/projects/occ/mixins.py:26 +msgid "The version must be an integer" +msgstr "版本須為整數值 " + +#: taiga/projects/occ/mixins.py:49 +msgid "The version parameter is not valid" +msgstr "本版本參數無效" + +#: taiga/projects/occ/mixins.py:65 +msgid "The version doesn't match with the current one" +msgstr "版本與目前使用不相符" + +#: taiga/projects/occ/mixins.py:84 +msgid "version" +msgstr "版本" + +#: taiga/projects/permissions.py:34 +msgid "" +"You can't leave the project if you are the owner or there are no more admins" +msgstr "" + +#: taiga/projects/services/invitations.py:64 +msgid "Token does not match any valid invitation." +msgstr "代碼無法與任何有效的邀請匹配。" + +#: taiga/projects/services/invitations.py:74 +#, fuzzy +#| msgid "The project doesn't exist" +msgid "User does not exist." +msgstr "專案不存在" + +#: taiga/projects/services/invitations.py:81 +msgid "This user is already a member of the project." +msgstr "使用者已是專案成員。" + +#: taiga/projects/services/members.py:46 +#, fuzzy +#| msgid "new email address" +msgid "Malformed email adress." +msgstr "新電子郵件地址" + +#: taiga/projects/services/members.py:134 +#: taiga/projects/services/members.py:181 +msgid "Project without owner" +msgstr "" + +#: taiga/projects/services/members.py:157 +#: taiga/projects/services/members.py:204 +msgid "You have reached your current limit of memberships for private projects" +msgstr "" + +#: taiga/projects/services/members.py:160 +#: taiga/projects/services/members.py:207 +msgid "You have reached your current limit of memberships for public projects" +msgstr "" + +#: taiga/projects/services/members.py:166 +#: taiga/projects/services/members.py:213 +msgid "You have reached the current limit of pending memberships" +msgstr "" + +#: taiga/projects/services/promote.py:39 +#, python-format +msgid "Task #%(ref)s" +msgstr "" + +#: taiga/projects/services/stats.py:186 +msgid "Future sprint" +msgstr "未來之衝刺" + +#: taiga/projects/services/stats.py:206 +msgid "Project End" +msgstr "專案結束" + +#: taiga/projects/services/transfer.py:51 +#: taiga/projects/services/transfer.py:58 +#: taiga/projects/services/transfer.py:61 taiga/users/api.py:189 +msgid "Token is invalid" +msgstr "代號無效" + +#: taiga/projects/services/transfer.py:56 +msgid "Token has expired" +msgstr "" + +#: taiga/projects/settings/choices.py:22 +msgid "Timeline" +msgstr "" + +#: taiga/projects/settings/choices.py:23 +msgid "Epics" +msgstr "" + +#: taiga/projects/settings/choices.py:24 +msgid "Backlog" +msgstr "" + +#. Translators: Name of kanban project template. +#: taiga/projects/settings/choices.py:25 taiga/projects/translations.py:24 +msgid "Kanban" +msgstr "Kanban" + +#: taiga/projects/settings/choices.py:26 +msgid "Issues" +msgstr "" + +#: taiga/projects/settings/choices.py:27 +msgid "TeamWiki" +msgstr "" + +#: taiga/projects/settings/validators.py:26 +msgid "You don't have access to this section" +msgstr "" + +#: taiga/projects/tagging/fields.py:41 +#, python-brace-format +msgid "Invalid tag '{value}'. The color is not a valid HEX color or null." +msgstr "" + +#: taiga/projects/tagging/fields.py:44 +#, python-brace-format +msgid "" +"Invalid tag '{value}'. it must be the name or a pair '[\"name\", \"hex color/" +"\" | null]'." +msgstr "" + +#: taiga/projects/tagging/fields.py:66 +#, python-brace-format +msgid "Invalid tag '{value}'. It must be the tag name." +msgstr "" + +#: taiga/projects/tagging/models.py:15 +msgid "tags" +msgstr "標籤" + +#: taiga/projects/tagging/models.py:23 +msgid "tags colors" +msgstr "標籤顏色" + +#: taiga/projects/tagging/validators.py:36 +#: taiga/projects/tagging/validators.py:63 +msgid "This tag already exists." +msgstr "" + +#: taiga/projects/tagging/validators.py:43 +#: taiga/projects/tagging/validators.py:70 +msgid "The color is not a valid HEX color." +msgstr "" + +#: taiga/projects/tagging/validators.py:56 +#: taiga/projects/tagging/validators.py:90 +#: taiga/projects/tagging/validators.py:103 +#: taiga/projects/tagging/validators.py:110 +msgid "The tag doesn't exist." +msgstr "" + +#: taiga/projects/tasks/api.py:102 taiga/projects/tasks/api.py:111 +msgid "You don't have permissions to set this sprint to this task." +msgstr "無權限更動此任務下的衝刺任務" + +#: taiga/projects/tasks/api.py:105 +msgid "You don't have permissions to set this user story to this task." +msgstr "無權限更動此務下的使用者故事" + +#: taiga/projects/tasks/api.py:108 +msgid "You don't have permissions to set this status to this task." +msgstr "無權限更動此任務下的狀態" + +#: taiga/projects/tasks/models.py:79 +msgid "us order" +msgstr "使用者故事次序" + +#: taiga/projects/tasks/models.py:81 +msgid "taskboard order" +msgstr "任務板次序" + +#: taiga/projects/tasks/models.py:95 +msgid "is iocaine" +msgstr "挑戰全新任務" + +#: taiga/projects/tasks/validators.py:50 +msgid "Invalid milestone id." +msgstr "" + +#: taiga/projects/tasks/validators.py:61 +msgid "Invalid task status id." +msgstr "" + +#: taiga/projects/tasks/validators.py:74 +msgid "Invalid user story id." +msgstr "" + +#: taiga/projects/tasks/validators.py:98 +msgid "Invalid task status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:112 +msgid "Invalid user story id. The user story must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:124 +#: taiga/projects/userstories/validators.py:112 +msgid "Invalid milestone id. The milestone must belong to the same project." +msgstr "" + +#: taiga/projects/tasks/validators.py:141 +msgid "" +"Invalid task ids. All tasks must belong to the same project and, if it " +"exists, to the same status, user story and/or milestone." +msgstr "" + +#: taiga/projects/tasks/validators.py:175 +msgid "All the tasks must be from the same project" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:14 +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:12 +msgid "someone" +msgstr "某人" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:19 +#, python-format +msgid "" +"\n" +"

You have been invited to %(product_name)s!

\n" +"

Hi! %(full_name)s has sent you an invitation to join project " +"%(project)s in %(product_name)s.
Taiga is an Open Source Agile " +"Project Management Tool. There is no cost for you to be a Taiga user.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:25 +#, python-format +msgid "" +"\n" +"

And now a few words from the jolly good fellow or sistren
" +"who thought so kindly as to invite you

\n" +"

%(extra)s

\n" +" " +msgstr "" +"\n" +"

來自團隊伙伴的一些話
他們希望邀請您

\n" +"

%(extra)s

" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation to Taiga" +msgstr "接受您的邀請使用Taigai" + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:32 +msgid "Accept your invitation" +msgstr "接受您的邀請 " + +#: taiga/projects/templates/emails/membership_invitation-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:33 +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:24 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:32 +#, python-format +msgid "" +"\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:14 +#, python-format +msgid "" +"\n" +"You, or someone you know, has invited you to %(product_name)s\n" +"\n" +"Hi! %(full_name)s has sent you an invitation to join a project called " +"%(project)s in %(product_name)s.\n" +"Taiga is an Open Source Agile Project Management Tool. There is no cost for " +"you to be a Taiga user.\n" +"\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:22 +#, python-format +msgid "" +"\n" +"And now a few words from the jolly good fellow or sistren who thought so " +"kindly as to invite you:\n" +"\n" +"%(extra)s\n" +" " +msgstr "" +"\n" +"來自團隊伙伴的一些話,他們希望邀請您:\n" +"\n" +"%(extra)s " + +#: taiga/projects/templates/emails/membership_invitation-body-text.jinja:28 +msgid "Accept your invitation to Taiga following this link:" +msgstr "接受Taiga加入邀請請依下面連結指示" + +#: taiga/projects/templates/emails/membership_invitation-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Invitation to join to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga]邀請加入專案t '%(project)s\n" + +#: taiga/projects/templates/emails/membership_notification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

You have been added to a project

\n" +"

Hello %(full_name)s,
you have been added to the project " +"%(project)s

\n" +" Go to " +"project\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"You have been added to a project\n" +"\n" +"Hello %(full_name)s,you have been added to the project %(project)s\n" +"\n" +"See project at %(url)s\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/projects/templates/emails/membership_notification-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[Taiga] Added to the project '%(project)s'\n" +msgstr "" +"\n" +"[Taiga] 被加入專案 '%(project)s'\n" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(old_owner_name)s,

\n" +"

%(new_owner_name)s has accepted your offer and will become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:18 +#, python-format +msgid "

%(new_owner_name)s says:

" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-html.jinja:22 +msgid "" +"\n" +"

From now on, your new status for this project will be \"admin\".\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(old_owner_name)s,\n" +"%(new_owner_name)s has accepted your offer and will become the new project " +"owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:15 +#, python-format +msgid "%(new_owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-body-text.jinja:19 +msgid "" +"\n" +"From now on, your new status for this project will be \"admin\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_accept-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer accepted!\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(rejecter_name)s has declined your offer and will not become the " +"new project owner of \"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(rejecter_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:24 +msgid "" +"\n" +"

If you want, you can still try to transfer the project ownership to a " +"different person.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:29 +#: taiga/projects/templates/emails/transfer_reject-body-html.jinja:30 +msgid "Request transfer to a different person" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(rejecter_name)s has declined your offer and will not become the new " +"project owner of \"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:15 +#, python-format +msgid "%(rejecter_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:19 +msgid "" +"\n" +"If you want, you can still try to transfer the project ownership to a " +"different person.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-body-text.jinja:23 +msgid "Request transfer to a different person:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_reject-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer declined\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(owner_name)s,

\n" +"

%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:17 +msgid "" +"\n" +"

Please, click on \"Continue\" if you would like to start the " +"project transfer from the administration panel.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-html.jinja:22 +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:30 +msgid "Continue" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(owner_name)s,\n" +"%(requester_name)s has requested to become the project owner of " +"\"%(project_name)s\".\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:14 +msgid "" +"\n" +"Please, go to your project settings if you would like to start the project " +"transfer from the administration panel.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-body-text.jinja:18 +msgid "Go to your project settings:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_request-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer request\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Hi %(receiver_name)s,

\n" +"

%(owner_name)s, the current project owner at \"%(project_name)s\" " +"would like you to become the new project owner.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:18 +#, python-format +msgid "" +"\n" +"

%(owner_name)s says:

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-html.jinja:25 +msgid "" +"\n" +"

Please, click on \"Continue\" to either accept or reject this " +"proposal.

\n" +" " +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hi %(receiver_name)s,\n" +"%(owner_name)s, the current project owner at \"%(project_name)s\" would like " +"you to become the new project owner.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:14 +#, python-format +msgid "%(owner_name)s says:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:19 +msgid "" +"\n" +"Please, go to the following link to either accept or reject this proposal.\n" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-body-text.jinja:23 +msgid "Accept or reject the project ownership transfer:" +msgstr "" + +#: taiga/projects/templates/emails/transfer_start-subject.jinja:9 +#, python-format +msgid "" +"\n" +"[%(project)s] Project ownership transfer offer\n" +msgstr "" + +#. Translators: Name of scrum project template. +#: taiga/projects/translations.py:19 +msgid "Scrum" +msgstr "Scrum" + +#. Translators: Description of scrum project template. +#: taiga/projects/translations.py:21 +msgid "" +"The agile product backlog in Scrum is a prioritized features list, " +"containing short descriptions of all functionality desired in the product. " +"When applying Scrum, it's not necessary to start a project with a lengthy, " +"upfront effort to document all requirements. The Scrum product backlog is " +"then allowed to grow and change as more is learned about the product and its " +"customers" +msgstr "" +"Scrum敏捷的產品未完工作是以特徵優先列表,包括簡短地描述期望的功能。使用Scrum" +"時,專案不一定要從冗長前期的力氣去記錄所有要求。Scrum的產品未完工作可依從它與" +"客戶中學到的回應,加以改變或調整。" + +#. Translators: Description of kanban project template. +#: taiga/projects/translations.py:26 +msgid "" +"Kanban is a method for managing knowledge work with an emphasis on just-in-" +"time delivery while not overloading the team members. In this approach, the " +"process, from definition of a task to its delivery to the customer, is " +"displayed for participants to see and team members pull work from a queue." +msgstr "" +"Kanban為一種知識工作管理方式,強調即時傳送而不讓團隊成員有過度負擔.此方式中,從" +"定義任務到其傳送給客戶的過程,以參與者可以看到且成員從次序排列上推動來呈現" + +#. Translators: User story point value (value = undefined) +#: taiga/projects/translations.py:34 +msgid "?" +msgstr "?" + +#. Translators: User story point value (value = 0) +#: taiga/projects/translations.py:36 +msgid "0" +msgstr "0" + +#. Translators: User story point value (value = 0.5) +#: taiga/projects/translations.py:38 +msgid "1/2" +msgstr "1/2" + +#. Translators: User story point value (value = 1) +#: taiga/projects/translations.py:40 +msgid "1" +msgstr "1" + +#. Translators: User story point value (value = 2) +#: taiga/projects/translations.py:42 +msgid "2" +msgstr "2" + +#. Translators: User story point value (value = 3) +#: taiga/projects/translations.py:44 +msgid "3" +msgstr "3" + +#. Translators: User story point value (value = 5) +#: taiga/projects/translations.py:46 +msgid "5" +msgstr "5" + +#. Translators: User story point value (value = 8) +#: taiga/projects/translations.py:48 +msgid "8" +msgstr "8" + +#. Translators: User story point value (value = 10) +#: taiga/projects/translations.py:50 +msgid "10" +msgstr "10" + +#. Translators: User story point value (value = 13) +#: taiga/projects/translations.py:52 +msgid "13" +msgstr "13" + +#. Translators: User story point value (value = 20) +#: taiga/projects/translations.py:54 +msgid "20" +msgstr "20" + +#. Translators: User story point value (value = 40) +#: taiga/projects/translations.py:56 +msgid "40" +msgstr "40" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:64 taiga/projects/translations.py:87 +#: taiga/projects/translations.py:103 +msgid "New" +msgstr "新 " + +#. Translators: User story status +#: taiga/projects/translations.py:67 +msgid "Ready" +msgstr "準備好了" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:70 taiga/projects/translations.py:89 +#: taiga/projects/translations.py:105 +msgid "In progress" +msgstr "進行中" + +#. Translators: User story status +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:73 taiga/projects/translations.py:91 +#: taiga/projects/translations.py:107 +msgid "Ready for test" +msgstr "準備測試 " + +#. Translators: User story status +#: taiga/projects/translations.py:76 +msgid "Done" +msgstr "完成" + +#. Translators: User story status +#: taiga/projects/translations.py:79 +msgid "Archived" +msgstr "歸檔" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:93 taiga/projects/translations.py:109 +msgid "Closed" +msgstr "關閉" + +#. Translators: Task status +#. Translators: Issue status +#: taiga/projects/translations.py:95 taiga/projects/translations.py:111 +msgid "Needs Info" +msgstr "需求資訊" + +#. Translators: Issue status +#: taiga/projects/translations.py:113 +msgid "Postponed" +msgstr "延後" + +#. Translators: Issue status +#: taiga/projects/translations.py:115 +msgid "Rejected" +msgstr "拒絕 " + +#. Translators: Issue type +#: taiga/projects/translations.py:123 +msgid "Bug" +msgstr "系統錯誤" + +#. Translators: Issue type +#: taiga/projects/translations.py:125 +msgid "Question" +msgstr "問題" + +#. Translators: Issue type +#: taiga/projects/translations.py:127 +msgid "Enhancement" +msgstr "強化" + +#. Translators: Issue priority +#: taiga/projects/translations.py:135 +msgid "Low" +msgstr "低" + +#. Translators: Issue priority +#. Translators: Issue severity +#: taiga/projects/translations.py:137 taiga/projects/translations.py:150 +msgid "Normal" +msgstr "一般" + +#. Translators: Issue priority +#: taiga/projects/translations.py:139 +msgid "High" +msgstr "高" + +#. Translators: Issue severity +#: taiga/projects/translations.py:146 +msgid "Wishlist" +msgstr "願望清單" + +#. Translators: Issue severity +#: taiga/projects/translations.py:148 +msgid "Minor" +msgstr "次要" + +#. Translators: Issue severity +#: taiga/projects/translations.py:152 +msgid "Important" +msgstr "重要" + +#. Translators: Issue severity +#: taiga/projects/translations.py:154 +msgid "Critical" +msgstr "關鍵" + +#. Translators: User role +#: taiga/projects/translations.py:161 +msgid "UX" +msgstr "使用者介面" + +#. Translators: User role +#: taiga/projects/translations.py:163 +msgid "Design" +msgstr "設計" + +#. Translators: User role +#: taiga/projects/translations.py:165 +msgid "Front" +msgstr "前台" + +#. Translators: User role +#: taiga/projects/translations.py:167 +msgid "Back" +msgstr "後台" + +#. Translators: User role +#: taiga/projects/translations.py:169 +msgid "Product Owner" +msgstr "產品所有人" + +#. Translators: User role +#: taiga/projects/translations.py:171 +msgid "Stakeholder" +msgstr "利害關係人" + +#: taiga/projects/userstories/api.py:190 +msgid "You don't have permissions to set this sprint to this user story." +msgstr "無權限更動使用者故事的衝刺任務" + +#: taiga/projects/userstories/api.py:194 +msgid "You don't have permissions to set this status to this user story." +msgstr "無權限更動此使用者故事的狀態" + +#: taiga/projects/userstories/api.py:198 +msgid "You don't have permissions to set this swimlane to this user story." +msgstr "" + +#: taiga/projects/userstories/api.py:274 +#, python-brace-format +msgid "Invalid role id '{role_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:281 +#, python-brace-format +msgid "Invalid points id '{points_id}'" +msgstr "" + +#: taiga/projects/userstories/api.py:300 +#, python-brace-format +msgid "Generating the user story #{ref} - {subject}" +msgstr "産生使用者故事 #{ref} - {subject}" + +#: taiga/projects/userstories/models.py:39 +msgid "role" +msgstr "角色" + +#: taiga/projects/userstories/models.py:95 +msgid "backlog order" +msgstr "待辦任務先後次序" + +#: taiga/projects/userstories/models.py:97 +msgid "sprint order" +msgstr "衝刺次序" + +#: taiga/projects/userstories/models.py:99 +msgid "kanban order" +msgstr "" + +#: taiga/projects/userstories/models.py:107 +msgid "finish date" +msgstr "完成日期" + +#: taiga/projects/userstories/models.py:122 +msgid "assigned users" +msgstr "" + +#: taiga/projects/userstories/models.py:131 +msgid "generated from issue" +msgstr "産生自問題 " + +#: taiga/projects/userstories/models.py:135 +msgid "generated from task" +msgstr "" + +#: taiga/projects/userstories/models.py:136 +msgid "reference from task" +msgstr "" + +#: taiga/projects/userstories/models.py:144 +msgid "swimlane" +msgstr "" + +#: taiga/projects/userstories/validators.py:33 +msgid "There's no user story with that id" +msgstr "該ID無相關使用者故事" + +#: taiga/projects/userstories/validators.py:74 +#: taiga/projects/userstories/validators.py:185 +msgid "" +"Invalid user story status id. The status must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:87 +#: taiga/projects/userstories/validators.py:197 +msgid "Invalid swimlane id. The swimlane must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:130 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:140 +#: taiga/projects/userstories/validators.py:226 +msgid "You can't use after and before at the same time." +msgstr "" + +#: taiga/projects/userstories/validators.py:153 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project and milestone." +msgstr "" + +#: taiga/projects/userstories/validators.py:165 +#: taiga/projects/userstories/validators.py:252 +msgid "Invalid user story ids. All stories must belong to the same project." +msgstr "" + +#: taiga/projects/userstories/validators.py:216 +msgid "" +"Invalid user story id to move after. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/userstories/validators.py:240 +msgid "" +"Invalid user story id to move before. The user story must belong to the same " +"project, status and swimlane." +msgstr "" + +#: taiga/projects/validators.py:52 +msgid "There's no project with that id" +msgstr "該ID無相關專案" + +#: taiga/projects/validators.py:165 +msgid "The user yet exists in the project" +msgstr "" + +#: taiga/projects/validators.py:170 +msgid "Invalid operation" +msgstr "" + +#: taiga/projects/validators.py:182 +msgid "Invalid role for the project" +msgstr "專案無效的角色" + +#: taiga/projects/validators.py:197 taiga/projects/validators.py:260 +msgid "The user must be a valid contact" +msgstr "" + +#: taiga/projects/validators.py:218 +msgid "The project owner must be admin." +msgstr "" + +#: taiga/projects/validators.py:222 +msgid "At least one user must be an active admin for this project." +msgstr "" + +#: taiga/projects/validators.py:275 +msgid "Invalid role ids. All roles must belong to the same project." +msgstr "" + +#: taiga/projects/validators.py:299 +msgid "Default options" +msgstr "預設選項" + +#: taiga/projects/validators.py:300 +msgid "User story's statuses" +msgstr "使用者故事狀態" + +#: taiga/projects/validators.py:301 +msgid "Points" +msgstr "點數" + +#: taiga/projects/validators.py:302 +msgid "Task's statuses" +msgstr "任務狀態" + +#: taiga/projects/validators.py:303 +msgid "Issue's statuses" +msgstr "問題狀態" + +#: taiga/projects/validators.py:304 +msgid "Issue's types" +msgstr "問題類型" + +#: taiga/projects/validators.py:305 +msgid "Priorities" +msgstr "優先性" + +#: taiga/projects/validators.py:306 +msgid "Severities" +msgstr "嚴重性" + +#: taiga/projects/validators.py:307 +msgid "Roles" +msgstr "角色" + +#: taiga/projects/votes/models.py:21 taiga/projects/votes/models.py:22 +#: taiga/projects/votes/models.py:52 +msgid "Votes" +msgstr "投票數" + +#: taiga/projects/votes/models.py:51 +msgid "Vote" +msgstr "投票 " + +#: taiga/projects/wiki/api.py:66 +msgid "'content' parameter is mandatory" +msgstr "'content'參數為必要" + +#: taiga/projects/wiki/api.py:69 +msgid "'project_id' parameter is mandatory" +msgstr "'project_id'參數為必要" + +#: taiga/projects/wiki/models.py:47 +msgid "last modifier" +msgstr "上次更改" + +#: taiga/projects/wiki/models.py:85 +msgid "href" +msgstr "href" + +#: taiga/telemetry/models.py:18 +msgid "instance id" +msgstr "" + +#: taiga/timeline/signals.py:54 +msgid "Check the history API for the exact diff" +msgstr "檢查API過去資料以找出差異" + +#: taiga/users/admin.py:31 +msgid "Project Member" +msgstr "" + +#: taiga/users/admin.py:32 +msgid "Project Members" +msgstr "" + +#: taiga/users/admin.py:41 +msgid "id" +msgstr "" + +#: taiga/users/admin.py:83 +msgid "Project Ownership" +msgstr "" + +#: taiga/users/admin.py:84 +msgid "Project Ownerships" +msgstr "" + +#: taiga/users/admin.py:97 +#, fuzzy +#| msgid "members" +msgid "Memberships" +msgstr "成員" + +#: taiga/users/admin.py:141 +msgid "PERSONAL INFO" +msgstr "" + +#: taiga/users/admin.py:142 +msgid "EXTRA INFO" +msgstr "" + +#: taiga/users/admin.py:144 +msgid "PERMISSIONS" +msgstr "" + +#: taiga/users/admin.py:145 +msgid "IMPORTANT DATES" +msgstr "" + +#: taiga/users/admin.py:146 +msgid "PROJECT OWNERSHIPS RESTRICTIONS" +msgstr "" + +#: taiga/users/admin.py:148 +msgid "PROJECT OWNERSHIPS STATS" +msgstr "" + +#: taiga/users/admin.py:169 +#, fuzzy +#| msgid "Project End" +msgid "Private projects owned" +msgstr "專案結束" + +#: taiga/users/admin.py:175 +msgid "Private memberships owned" +msgstr "" + +#: taiga/users/admin.py:193 +#, fuzzy +#| msgid "Project End" +msgid "Public projects owned" +msgstr "專案結束" + +#: taiga/users/admin.py:199 +msgid "Public memberships owned" +msgstr "" + +#: taiga/users/api.py:123 +msgid "Duplicated email" +msgstr "複製電子郵件" + +#: taiga/users/api.py:125 +msgid "Invalid email" +msgstr "" + +#: taiga/users/api.py:163 +msgid "Invalid username or email" +msgstr "無效使用者或郵件" + +#: taiga/users/api.py:172 +msgid "Mail sent successfully!" +msgstr "" + +#: taiga/users/api.py:210 +msgid "Current password parameter needed" +msgstr "需要目前密碼之參數" + +#: taiga/users/api.py:213 +msgid "New password parameter needed" +msgstr "需要新密碼參數" + +#: taiga/users/api.py:216 +msgid "Invalid password length at least 6 characters needed" +msgstr "" + +#: taiga/users/api.py:219 +msgid "Invalid current password" +msgstr "無效密碼" + +#: taiga/users/api.py:271 +msgid "" +"Invalid, are you sure the token is correct and you didn't use it before?" +msgstr "無效,請確認代號正確,之前是否曾使用過?" + +#: taiga/users/api.py:312 taiga/users/api.py:319 taiga/users/api.py:322 +msgid "Invalid, are you sure the token is correct?" +msgstr "無效,請確認代號是否正確?" + +#: taiga/users/api.py:344 +msgid "Email address already verified" +msgstr "" + +#: taiga/users/api.py:346 +msgid "Unable to verify this email address" +msgstr "" + +#: taiga/users/api.py:349 +msgid "Mail sended successful!" +msgstr "成功送出郵件" + +#: taiga/users/models.py:87 +msgid "superuser status" +msgstr "超級使用者狀態 " + +#: taiga/users/models.py:88 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "無經明確分派,即賦予該使用者所有權限," + +#: taiga/users/models.py:120 +msgid "username" +msgstr "使用者名稱" + +#: taiga/users/models.py:121 +msgid "" +"Required. 30 characters or fewer. Letters, numbers and /./-/_ characters" +msgstr "必填。最多30字元(可為數字,字母,符號....)" + +#: taiga/users/models.py:124 +msgid "Enter a valid username." +msgstr "輸入有效的使用者名稱 " + +#: taiga/users/models.py:127 +msgid "active" +msgstr "活躍" + +#: taiga/users/models.py:128 +msgid "" +"Designates whether this user should be treated as active. Unselect this " +"instead of deleting accounts." +msgstr "賦予該使用者活躍角色,以不選擇取代刪除帳戶功能。" + +#: taiga/users/models.py:130 +msgid "staff status" +msgstr "" + +#: taiga/users/models.py:131 +msgid "Designates whether the user can log into this admin site." +msgstr "" + +#: taiga/users/models.py:137 +msgid "biography" +msgstr "自傳" + +#: taiga/users/models.py:140 +msgid "photo" +msgstr "照片" + +#: taiga/users/models.py:141 +msgid "date joined" +msgstr "加入日期" + +#: taiga/users/models.py:142 +#, fuzzy +#| msgid "date joined" +msgid "date cancelled" +msgstr "加入日期" + +#: taiga/users/models.py:143 +msgid "accepted terms" +msgstr "" + +#: taiga/users/models.py:144 +msgid "new terms read" +msgstr "" + +#: taiga/users/models.py:146 +msgid "default language" +msgstr "預設語言 " + +#: taiga/users/models.py:148 +msgid "default theme" +msgstr "預設主題" + +#: taiga/users/models.py:150 +msgid "default timezone" +msgstr "預設時區" + +#: taiga/users/models.py:152 +msgid "colorize tags" +msgstr "顏色標籤" + +#: taiga/users/models.py:157 +msgid "email token" +msgstr "電子郵件符號 " + +#: taiga/users/models.py:159 +msgid "new email address" +msgstr "新電子郵件地址" + +#: taiga/users/models.py:166 +msgid "max number of owned private projects" +msgstr "" + +#: taiga/users/models.py:169 +msgid "max number of owned public projects" +msgstr "" + +#: taiga/users/models.py:172 +msgid "" +"max number of memberships of different users for all owned private project" +msgstr "" + +#: taiga/users/models.py:177 +msgid "" +"max number of memberships of different users for all owned public project" +msgstr "" + +#: taiga/users/models.py:305 +msgid "permissions" +msgstr "許可" + +#: taiga/users/services.py:46 taiga/users/services.py:63 +msgid "Username or password does not matches user." +msgstr "用戶名稱與密碼不符" + +#: taiga/users/templates/emails/change_email-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Change your email

\n" +"

Hello %(full_name)s,
please confirm your email

\n" +" Confirm " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/change_email-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please confirm your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/change_email-subject.jinja:9 +msgid "[Taiga] Change email" +msgstr "[Taiga]更換電子郵件" + +#: taiga/users/templates/emails/password_recovery-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Recover your password

\n" +"

Hello %(full_name)s,
you asked to recover your password

\n" +" Recover your password\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/password_recovery-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, you asked to recover your password\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/password_recovery-subject.jinja:9 +msgid "[Taiga] Password recovery" +msgstr "[Taiga] 密碼恢復 " + +#: taiga/users/templates/emails/registered_user-body-html.jinja:16 +#, python-format +msgid "" +"\n" +"

Please confirm your email

\n" +" Confirm email\n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:21 +#, python-format +msgid "" +"\n" +"

Thank you for registering in %(product_name)s

\n" +"

We're thrilled you've joined our growing community of " +"professionals revolutionizing their way of working and hope you enjoy it.\n" +"

Have a question? Give us a nudge if you ever need a helping " +"hand, we're at %(support_email)s.

\n" +"

%(signature)s

\n" +" \n" +" " +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-html.jinja:36 +#, python-format +msgid "" +"\n" +" You may remove your account from this service clicking here\n" +" " +msgstr "" +"\n" +"您將自本服務刪除帳號 點擊此處" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Thank you for registering in %(product_name)s\n" +"\n" +"We're thrilled you've joined our growing community of professionals " +"revolutionizing their way of working and hope you enjoy it.\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:16 +#, python-format +msgid "" +"\n" +"Please confirm your email: %(url)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:21 +#, python-format +msgid "" +"\n" +"Have a question? Give us a nudge if you ever need a helping hand, we're at " +"%(support_email)s.\n" +"--\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/registered_user-body-text.jinja:26 +#, python-format +msgid "" +"\n" +"You may remove your account from this service: %(url)s\n" +msgstr "" +"\n" +"您可以自本服務移除帳戶: %(url)s\n" + +#: taiga/users/templates/emails/registered_user-subject.jinja:9 +msgid "You've been Taigatized!" +msgstr "您已加入Taiga" + +#: taiga/users/templates/emails/send_verification-body-html.jinja:12 +#, python-format +msgid "" +"\n" +"

Verify your email

\n" +"

Hello %(full_name)s,
please verify your email

\n" +" Verify " +"email\n" +"

You can ignore this message if you did not request.

\n" +"

%(signature)s

\n" +" " +msgstr "" + +#: taiga/users/templates/emails/send_verification-body-text.jinja:9 +#, python-format +msgid "" +"\n" +"Hello %(full_name)s, please verify your email\n" +"\n" +"%(url)s\n" +"\n" +"You can ignore this message if you did not request.\n" +"\n" +"---\n" +"%(signature)s\n" +msgstr "" + +#: taiga/users/templates/emails/send_verification-subject.jinja:9 +msgid "[Taiga] Verify email" +msgstr "" + +#: taiga/users/validators.py:38 +msgid "invalid" +msgstr "無效" + +#: taiga/users/validators.py:49 +msgid "Invalid username. Try with a different one." +msgstr "無效使用者名稱,請重試其它名稱 " + +#: taiga/users/validators.py:76 +msgid "Read new terms has to be true'" +msgstr "" + +#: taiga/userstorage/api.py:42 +msgid "" +"Duplicate key value violates unique constraint. Key '{}' already exists." +msgstr "複製的關鍵值侵害獨特約束 關鍵值'{}' 已存在" + +#: taiga/userstorage/models.py:27 +msgid "key" +msgstr "關鍵值" + +#: taiga/webhooks/models.py:24 taiga/webhooks/models.py:39 +msgid "URL" +msgstr "URL" + +#: taiga/webhooks/models.py:25 +msgid "secret key" +msgstr "袐密代碼" + +#: taiga/webhooks/models.py:40 +msgid "status code" +msgstr "狀態碼" + +#: taiga/webhooks/models.py:41 +msgid "request data" +msgstr "要求資料" + +#: taiga/webhooks/models.py:42 +msgid "request headers" +msgstr "要求標頭" + +#: taiga/webhooks/models.py:43 +msgid "response data" +msgstr "回應資料" + +#: taiga/webhooks/models.py:44 +msgid "response headers" +msgstr "回應標頭 " + +#: taiga/webhooks/models.py:45 +msgid "duration" +msgstr "期間" + +#: taiga/webhooks/validators.py:32 +msgid "Not allowed IP Address" +msgstr "" + +#~ msgid "Personal info" +#~ msgstr "個人資訊" + +#~ msgid "Permissions" +#~ msgstr "許可" + +#~ msgid "Important dates" +#~ msgstr "重要日期" + +#~ msgid "Twitter" +#~ msgstr "Twitter" + +#~ msgid "GitHub" +#~ msgstr "GitHub" + +#~ msgid "Visit our website" +#~ msgstr "Visit our website" + +#~ msgid "Taiga.io" +#~ msgstr "Taiga.io" + +#~ msgid "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " Configure " +#~ "email notifications or unsubscribe:\n" +#~ " %(unsubscribe_url)s\n" +#~ "
\n" +#~ " Taiga Support:" +#~ "\n" +#~ " %(support_url)s\n" +#~ "
\n" +#~ " Contact us:\n" +#~ " \n" +#~ " %(support_email)s\n" +#~ " \n" +#~ "
\n" +#~ " Mailing list:" +#~ "\n" +#~ " \n" +#~ " %(mailing_list_url)s\n" +#~ " \n" +#~ " " + +#~ msgid "The Taiga Team" +#~ msgstr "The Taiga Team" + +#~ msgid "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "---\n" +#~ "The Taiga Team\n" + +#~ msgid "" +#~ "\n" +#~ "The Taiga Team\n" +#~ msgstr "" +#~ "\n" +#~ "The Taiga Team\n" diff --git a/taiga/mdrender/__init__.py b/taiga/mdrender/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/mdrender/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/mdrender/extensions/__init__.py b/taiga/mdrender/extensions/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/mdrender/extensions/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/mdrender/extensions/autolink.py b/taiga/mdrender/extensions/autolink.py new file mode 100644 index 000000000..d1f36163b --- /dev/null +++ b/taiga/mdrender/extensions/autolink.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. + +import re +import markdown +from markdown import inlinepatterns +from markdown.util import AtomicString +from xml.etree import ElementTree as etree + +# We can't re-use the built-in AutolinkPattern because we need to add protocols +# to links without them. +class AutolinkPattern(inlinepatterns.Pattern): + def handleMatch(self, m): + el = etree.Element("a") + + href = m.group(2) + if not re.match('^(ftp|https?)://', href, flags=re.IGNORECASE): + href = 'http://%s' % href + el.set('href', self.unescape(href)) + + el.text = AtomicString(m.group(2)) + return el + + +class AutolinkExtension(markdown.Extension): + """An extension that turns all URLs into links.""" + def extendMarkdown(self, md): + url_re = '(%s)' % '|'.join([ + r'<(?:([Ff][Tt][Pp])|([Hh][Tt])[Tt][Pp][Ss]?)://[^>]*>', + r'\b(?:([Ff][Tt][Pp])|([Hh][Tt])[Tt][Pp][Ss]?)://[^)<>\s]+[^.,)<>\s]', + r'\bwww\.[^)<>\s]+[^.,)<>\s]', + ]) + autolink = AutolinkPattern(url_re, md) + md.inlinePatterns.register(autolink, 'autolink', 70) diff --git a/taiga/mdrender/extensions/automail.py b/taiga/mdrender/extensions/automail.py new file mode 100644 index 000000000..a33bec13d --- /dev/null +++ b/taiga/mdrender/extensions/automail.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. + +from markdown import Extension +from markdown.inlinepatterns import Pattern +from markdown.util import AtomicString +from xml.etree import ElementTree as etree + + +# We can't re-use the built-in AutomailPattern because we need to add mailto:. +# We also don't care about HTML-encoding the email. +class AutomailPattern(Pattern): + def handleMatch(self, m): + el = etree.Element("a") + el.set('href', self.unescape('mailto:' + m.group(2))) + el.text = AtomicString(m.group(2)) + return el + + +class AutomailExtension(Extension): + """An extension that turns all email addresses into links.""" + + def extendMarkdown(self, md): + mail_re = r'\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]+)\b' + automail = AutomailPattern(mail_re, md) + md.inlinePatterns.register(automail, 'automail', 70) diff --git a/taiga/mdrender/extensions/emojify.py b/taiga/mdrender/extensions/emojify.py new file mode 100644 index 000000000..a20dbc80b --- /dev/null +++ b/taiga/mdrender/extensions/emojify.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + +# Tested on Markdown 2.3.1 +# +# Copyright (c) 2014, Esteban Castro Borsani +# Copyright (c) 2014, Jesús Espino García +# The MIT License (MIT) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +import re + +from django.templatetags.static import static + + +from markdown.extensions import Extension +from markdown.preprocessors import Preprocessor + + +# Grab the emojis (+800) here: https://github.com/arvida/emoji-cheat-sheet.com +# This **crazy long** list was generated by walking through the emojis.png +EMOJIS_PATH = "img/emojis/" +EMOJIS_SET = { + "+1", "-1", "100", "1234", "8ball", "a", "ab", "abc", "abcd", "accept", "aerial_tramway", "airplane", + "alarm_clock", "alien", "ambulance", "anchor", "angel", "anger", "angry", "anguished", "ant", "apple", + "aquarius", "aries", "arrows_clockwise", "arrows_counterclockwise", "arrow_backward", "arrow_double_down", + "arrow_double_up", "arrow_down", "arrow_down_small", "arrow_forward", "arrow_heading_down", "arrow_heading_up", + "arrow_left", "arrow_lower_left", "arrow_lower_right", "arrow_right", "arrow_right_hook", "arrow_up", + "arrow_upper_left", "arrow_upper_right", "arrow_up_down", "arrow_up_small", "art", "articulated_lorry", + "astonished", "atm", "b", "baby", "baby_bottle", "baby_chick", "baby_symbol", "baggage_claim", "balloon", + "ballot_box_with_check", "bamboo", "banana", "bangbang", "bank", "barber", "bar_chart", "baseball", "basketball", + "bath", "bathtub", "battery", "bear", "bee", "beer", "beers", "beetle", "beginner", "bell", "bento", "bicyclist", + "bike", "bikini", "bird", "birthday", "black_circle", "black_joker", "black_nib", "black_square", + "black_square_button", "blossom", "blowfish", "blue_book", "blue_car", "blue_heart", "blush", "boar", "boat", + "bomb", "book", "bookmark", "bookmark_tabs", "books", "boom", "boot", "bouquet", "bow", "bowling", "bowtie", + "boy", "bread", "bride_with_veil", "bridge_at_night", "briefcase", "broken_heart", "bug", "bulb", + "bullettrain_front", "bullettrain_side", "bus", "busstop", "busts_in_silhouette", "bust_in_silhouette", + "cactus", "cake", "calendar", "calling", "camel", "camera", "cancer", "candy", "capital_abcd", "capricorn", + "car", "card_index", "carousel_horse", "cat", "cat2", "cd", "chart", "chart_with_downwards_trend", + "chart_with_upwards_trend", "checkered_flag", "cherries", "cherry_blossom", "chestnut", "chicken", + "children_crossing", "chocolate_bar", "christmas_tree", "church", "cinema", "circus_tent", "city_sunrise", + "city_sunset", "cl", "clap", "clapper", "clipboard", "clock1", "clock10", "clock1030", "clock11", "clock1130", + "clock12", "clock1230", "clock130", "clock2", "clock230", "clock3", "clock330", "clock4", "clock430", "clock5", + "clock530", "clock6", "clock630", "clock7", "clock730", "clock8", "clock830", "clock9", "clock930", + "closed_book", "closed_lock_with_key", "closed_umbrella", "cloud", "clubs", "cn", "cocktail", "coffee", + "cold_sweat", "collision", "computer", "confetti_ball", "confounded", "confused", "congratulations", + "construction", "construction_worker", "convenience_store", "cookie", "cool", "cop", "copyright", "corn", + "couple", "couplekiss", "couple_with_heart", "cow", "cow2", "credit_card", "crocodile", "crossed_flags", + "crown", "cry", "crying_cat_face", "crystal_ball", "cupid", "curly_loop", "currency_exchange", "curry", + "custard", "customs", "cyclone", "dancer", "dancers", "dango", "dart", "dash", "date", "de", "deciduous_tree", + "department_store", "diamonds", "diamond_shape_with_a_dot_inside", "disappointed", "disappointed_relieved", + "dizzy", "dizzy_face", "dog", "dog2", "dollar", "dolls", "dolphin", "donut", "door", "doughnut", + "do_not_litter", "dragon", "dragon_face", "dress", "dromedary_camel", "droplet", "dvd", "e-mail", "ear", + "earth_africa", "earth_americas", "earth_asia", "ear_of_rice", "egg", "eggplant", "eight", + "eight_pointed_black_star", "eight_spoked_asterisk", "electric_plug", "elephant", "email", "end", "envelope", + "es", "euro", "european_castle", "european_post_office", "evergreen_tree", "exclamation", "expressionless", + "eyeglasses", "eyes", "facepunch", "factory", "fallen_leaf", "family", "fast_forward", "fax", "fearful", + "feelsgood", "feet", "ferris_wheel", "file_folder", "finnadie", "fire", "fireworks", "fire_engine", + "first_quarter_moon", "first_quarter_moon_with_face", "fish", "fishing_pole_and_fish", "fish_cake", "fist", + "five", "flags", "flashlight", "floppy_disk", "flower_playing_cards", "flushed", "foggy", "football", + "fork_and_knife", "fountain", "four", "four_leaf_clover", "fr", "free", "fried_shrimp", "fries", "frog", + "frowning", "fu", "fuelpump", "full_moon", "full_moon_with_face", "game_die", "gb", "gem", "gemini", "ghost", + "gift", "gift_heart", "girl", "globe_with_meridians", "goat", "goberserk", "godmode", "golf", "grapes", + "green_apple", "green_book", "green_heart", "grey_exclamation", "grey_question", "grimacing", "grin", + "grinning", "guardsman", "guitar", "gun", "haircut", "hamburger", "hammer", "hamster", "hand", "handbag", + "hankey", "hash", "hatched_chick", "hatching_chick", "headphones", "heart", "heartbeat", "heartpulse", + "hearts", "heart_decoration", "heart_eyes", "heart_eyes_cat", "hear_no_evil", "heavy_check_mark", + "heavy_division_sign", "heavy_dollar_sign", "heavy_exclamation_mark", "heavy_minus_sign", + "heavy_multiplication_x", "heavy_plus_sign", "helicopter", "herb", "hibiscus", "high_brightness", + "high_heel", "hocho", "honeybee", "honey_pot", "horse", "horse_racing", "hospital", "hotel", "hotsprings", + "hourglass", "hourglass_flowing_sand", "house", "house_with_garden", "hurtrealbad", "hushed", "icecream", + "ice_cream", "id", "ideograph_advantage", "imp", "inbox_tray", "incoming_envelope", "information_desk_person", + "information_source", "innocent", "interrobang", "iphone", "it", "izakaya_lantern", "jack_o_lantern", "japan", + "japanese_castle", "japanese_goblin", "japanese_ogre", "jeans", "joy", "joy_cat", "jp", "key", "keycap_ten", + "kimono", "kiss", "kissing", "kissing_cat", "kissing_closed_eyes", "kissing_face", "kissing_heart", + "kissing_smiling_eyes", "koala", "koko", "kr", "large_blue_circle", "large_blue_diamond", "large_orange_diamond", + "last_quarter_moon", "last_quarter_moon_with_face", "laughing", "leaves", "ledger", "leftwards_arrow_with_hook", + "left_luggage", "left_right_arrow", "lemon", "leo", "leopard", "libra", "light_rail", "link", "lips", + "lipstick", "lock", "lock_with_ink_pen", "lollipop", "loop", "loudspeaker", "love_hotel", "love_letter", + "low_brightness", "m", "mag", "mag_right", "mahjong", "mailbox", "mailbox_closed", "mailbox_with_mail", + "mailbox_with_no_mail", "man", "mans_shoe", "man_with_gua_pi_mao", "man_with_turban", "maple_leaf", "mask", + "massage", "meat_on_bone", "mega", "melon", "memo", "mens", "metal", "metro", "microphone", "microscope", + "milky_way", "minibus", "minidisc", "mobile_phone_off", "moneybag", "money_with_wings", "monkey", "monkey_face", + "monorail", "moon", "mortar_board", "mountain_bicyclist", "mountain_cableway", "mountain_railway", + "mount_fuji", "mouse", "mouse2", "movie_camera", "moyai", "muscle", "mushroom", "musical_keyboard", + "musical_note", "musical_score", "mute", "nail_care", "name_badge", "neckbeard", "necktie", + "negative_squared_cross_mark", "neutral_face", "new", "newspaper", "new_moon", "new_moon_with_face", + "ng", "nine", "non-potable_water", "nose", "notebook", "notebook_with_decorative_cover", "notes", "no_bell", + "no_bicycles", "no_entry", "no_entry_sign", "no_good", "no_mobile_phones", "no_mouth", "no_pedestrians", + "no_smoking", "nut_and_bolt", "o", "o2", "ocean", "octocat", "octopus", "oden", "office", "ok", "ok_hand", + "ok_woman", "older_man", "older_woman", "on", "oncoming_automobile", "oncoming_bus", "oncoming_police_car", + "oncoming_taxi", "one", "open_file_folder", "open_hands", "open_mouth", "ophiuchus", "orange_book", + "outbox_tray", "ox", "pager", "page_facing_up", "page_with_curl", "palm_tree", "panda_face", "paperclip", + "parking", "partly_sunny", "part_alternation_mark", "passport_control", "paw_prints", "peach", "pear", + "pencil", "pencil2", "penguin", "pensive", "performing_arts", "persevere", "person_frowning", + "person_with_blond_hair", "person_with_pouting_face", "phone", "pig", "pig2", "pig_nose", "pill", + "pineapple", "pisces", "pizza", "plus1", "point_down", "point_left", "point_right", "point_up", + "point_up_2", "police_car", "poodle", "poop", "postal_horn", "postbox", "post_office", "potable_water", + "pouch", "poultry_leg", "pound", "pouting_cat", "pray", "princess", "punch", "purple_heart", "purse", + "pushpin", "put_litter_in_its_place", "question", "rabbit", "rabbit2", "racehorse", "radio", "radio_button", + "rage", "rage1", "rage2", "rage3", "rage4", "railway_car", "rainbow", "raised_hand", "raised_hands", + "raising_hand", "ram", "ramen", "rat", "recycle", "red_car", "red_circle", "registered", "relaxed", + "relieved", "repeat", "repeat_one", "restroom", "revolving_hearts", "rewind", "ribbon", "rice", "rice_ball", + "rice_cracker", "rice_scene", "ring", "rocket", "roller_coaster", "rooster", "rose", "rotating_light", + "round_pushpin", "rowboat", "ru", "rugby_football", "runner", "running", "running_shirt_with_sash", "sa", + "sagittarius", "sailboat", "sake", "sandal", "santa", "satellite", "satisfied", "saxophone", "school", + "school_satchel", "scissors", "scorpius", "scream", "scream_cat", "scroll", "seat", "secret", "seedling", + "see_no_evil", "seven", "shaved_ice", "sheep", "shell", "ship", "shipit", "shirt", "shit", "shoe", "shower", + "signal_strength", "six", "six_pointed_star", "ski", "skull", "sleeping", "sleepy", "slot_machine", + "small_blue_diamond", "small_orange_diamond", "small_red_triangle", "small_red_triangle_down", "smile", + "smiley", "smiley_cat", "smile_cat", "smiling_imp", "smirk", "smirk_cat", "smoking", "snail", "snake", + "snowboarder", "snowflake", "snowman", "sob", "soccer", "soon", "sos", "sound", "space_invader", "spades", + "spaghetti", "sparkler", "sparkles", "sparkling_heart", "speaker", "speak_no_evil", "speech_balloon", + "speedboat", "squirrel", "star", "star2", "stars", "station", "statue_of_liberty", "steam_locomotive", + "stew", "straight_ruler", "strawberry", "stuck_out_tongue", "stuck_out_tongue_closed_eyes", + "stuck_out_tongue_winking_eye", "sunflower", "sunglasses", "sunny", "sunrise", "sunrise_over_mountains", + "sun_with_face", "surfer", "sushi", "suspect", "suspension_railway", "sweat", "sweat_drops", "sweat_smile", + "sweet_potato", "swimmer", "symbols", "syringe", "tada", "tanabata_tree", "tangerine", "taurus", "taxi", + "tea", "telephone", "telephone_receiver", "telescope", "tennis", "tent", "thought_balloon", "three", + "thumbsdown", "thumbsup", "ticket", "tiger", "tiger2", "tired_face", "tm", "toilet", "tokyo_tower", "tomato", + "tongue", "top", "tophat", "tractor", "traffic_light", "train", "train2", "tram", "triangular_flag_on_post", + "triangular_ruler", "trident", "triumph", "trolleybus", "trollface", "trophy", "tropical_drink", + "tropical_fish", "truck", "trumpet", "tshirt", "tulip", "turtle", "tv", "twisted_rightwards_arrows", + "two", "two_hearts", "two_men_holding_hands", "two_women_holding_hands", "u5272", "u5408", "u55b6", + "u6307", "u6708", "u6709", "u6e80", "u7121", "u7533", "u7981", "u7a7a", "uk", "umbrella", "unamused", + "underage", "unlock", "up", "us", "v", "vertical_traffic_light", "vhs", "vibration_mode", "video_camera", + "video_game", "violin", "virgo", "volcano", "vs", "walking", "waning_crescent_moon", "waning_gibbous_moon", + "warning", "watch", "watermelon", "water_buffalo", "wave", "wavy_dash", "waxing_crescent_moon", + "waxing_gibbous_moon", "wc", "weary", "wedding", "whale", "whale2", "wheelchair", "white_check_mark", + "white_circle", "white_flower", "white_square", "white_square_button", "wind_chime", "wine_glass", + "wink", "wolf", "woman", "womans_clothes", "womans_hat", "womens", "worried", "wrench", "x", "yellow_heart", + "yen", "yum", "zap", "zero", "zzz", +} + + +class EmojifyExtension(Extension): + + def extendMarkdown(self, md): + md.registerExtension(self) + md.preprocessors.register(EmojifyPreprocessor(md), + 'emojify', + 70) + + +class EmojifyPreprocessor(Preprocessor): + + def run(self, lines): + pattern = re.compile(r':([a-z0-9\+\-_]+):') + + new_lines = [] + + def emojify(match): + emoji = match.group(1) + + if emoji not in EMOJIS_SET: + return match.group(0) + + path = "{}{}.png".format(EMOJIS_PATH, emoji) + url = static(path) + return '![{emoji}]({url})'.format(emoji=emoji, url=url) + + for line in lines: + if line.strip(): + line = pattern.sub(emojify, line) + + new_lines.append(line) + + return new_lines diff --git a/taiga/mdrender/extensions/mentions.py b/taiga/mdrender/extensions/mentions.py new file mode 100644 index 000000000..7ded01a7e --- /dev/null +++ b/taiga/mdrender/extensions/mentions.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + +# Tested on Markdown 2.3.1 +# +# Copyright (c) 2014, Esteban Castro Borsani +# The MIT License (MIT) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from django.contrib.auth import get_user_model + +from markdown.extensions import Extension +from markdown.inlinepatterns import Pattern +from markdown.util import AtomicString +from xml.etree import ElementTree as etree +from taiga.front.templatetags.functions import resolve + + +class MentionsExtension(Extension): + project = None + + def __init__(self, *args, **kwargs): + self.project = kwargs.pop("project", None) + super().__init__(*args, **kwargs) + + def extendMarkdown(self, md): + MENTION_RE = r"\B(@)([\w.-]+)\b" + mentionsPattern = MentionsPattern(MENTION_RE, project=self.project) + mentionsPattern.md = md + md.inlinePatterns.register(mentionsPattern, "mentions", 80) + + +class MentionsPattern(Pattern): + project = None + + def __init__(self, pattern, md=None, project=None): + self.project = project + super().__init__(pattern, md) + + def handleMatch(self, m): + username = m.group(3) + kwargs = {"username": username} + if self.project is not None: + kwargs["memberships__project_id"]=self.project.id + try: + user = get_user_model().objects.get(**kwargs) + except get_user_model().DoesNotExist: + return "@{}".format(username) + + url = resolve("user", username) + + link_text = "@{}".format(username) + + a = etree.Element('a') + a.text = AtomicString(link_text) + + a.set('href', url) + a.set('title', user.get_full_name()) + a.set('class', "mention") + + self.md.extracted_data['mentions'].append(user) + + return a diff --git a/taiga/mdrender/extensions/references.py b/taiga/mdrender/extensions/references.py new file mode 100644 index 000000000..30680e0f5 --- /dev/null +++ b/taiga/mdrender/extensions/references.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + +# Tested on Markdown 2.3.1 +# +# Copyright (c) 2014, Esteban Castro Borsani +# The MIT License (MIT) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from markdown.extensions import Extension +from markdown.inlinepatterns import Pattern +from xml.etree import ElementTree as etree + +from taiga.projects.references.services import get_instance_by_ref +from taiga.front.templatetags.functions import resolve + + +class TaigaReferencesExtension(Extension): + def __init__(self, project, *args, **kwargs): + self.project = project + return super().__init__(*args, **kwargs) + + def extendMarkdown(self, md): + TAIGA_REFERENCE_RE = r'(?<=^|(?<=[^a-zA-Z0-9-\[]))#(\d+)' + referencesPattern = TaigaReferencesPattern(TAIGA_REFERENCE_RE, self.project) + referencesPattern.md = md + md.inlinePatterns.register(referencesPattern, 'taiga-references', 65) + + +class TaigaReferencesPattern(Pattern): + def __init__(self, pattern, project): + self.project = project + super().__init__(pattern) + + def handleMatch(self, m): + obj_ref = m.group(2) + + instance = get_instance_by_ref(self.project.id, obj_ref) + if instance is None or instance.content_object is None: + return "#{}".format(obj_ref) + + subject = instance.content_object.subject + + if instance.content_type.model == "epic": + html_classes = "reference epic" + elif instance.content_type.model == "userstory": + html_classes = "reference user-story" + elif instance.content_type.model == "task": + html_classes = "reference task" + elif instance.content_type.model == "issue": + html_classes = "reference issue" + else: + return "#{}".format(obj_ref) + + url = resolve(instance.content_type.model, self.project.slug, obj_ref) + + link_text = "#{}".format(obj_ref) + + a = etree.Element('a') + a.text = link_text + a.set('href', url) + a.set('title', "#{} {}".format(obj_ref, subject)) + a.set('class', html_classes) + + self.md.extracted_data['references'].append(instance.content_object) + + return a diff --git a/taiga/mdrender/extensions/refresh_attachment.py b/taiga/mdrender/extensions/refresh_attachment.py new file mode 100644 index 000000000..b5c8d25c9 --- /dev/null +++ b/taiga/mdrender/extensions/refresh_attachment.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +import markdown +from markdown.treeprocessors import Treeprocessor + +from taiga.projects.attachments.services import ( + extract_refresh_id, get_attachment_by_id, generate_refresh_fragment, url_is_an_attachment +) + + +class RefreshAttachmentExtension(markdown.Extension): + """An extension that refresh attachment URL.""" + def __init__(self, *args, **kwargs): + self.project = kwargs.pop("project", None) + super().__init__(*args, **kwargs) + + def extendMarkdown(self, md): + md.treeprocessors.register(RefreshAttachmentTreeprocessor(md, project=self.project), + "refresh_attachment", + 20) + + +class RefreshAttachmentTreeprocessor(Treeprocessor): + def __init__(self, *args, **kwargs): + self.project = kwargs.pop("project", None) + super().__init__(*args, **kwargs) + + def run(self, root): + # Bypass if not project + if not self.project: + return + + for tag, attr in [("img", "src"), ("a", "href")]: + for el in root.iter(tag): + url = url_is_an_attachment(el.get(attr, "")) + if not url: + # It's not an attachment + break + + type_, attachment_id = extract_refresh_id(url) + if not attachment_id: + # There is no refresh parameter + break + + attachment = get_attachment_by_id(self.project.id, attachment_id) + if not attachment: + # Attachment not found or not permissions + break + + # Substitute url + frag = generate_refresh_fragment(attachment, type_) + new_url = "{}#{}".format(attachment.attached_file.url, frag) + el.set(attr, new_url) diff --git a/taiga/mdrender/extensions/semi_sane_lists.py b/taiga/mdrender/extensions/semi_sane_lists.py new file mode 100644 index 000000000..aff1acb88 --- /dev/null +++ b/taiga/mdrender/extensions/semi_sane_lists.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. + +import markdown + + +class SemiSaneOListProcessor(markdown.blockprocessors.OListProcessor): + SIBLING_TAGS = ['ol'] + + +class SemiSaneUListProcessor(markdown.blockprocessors.UListProcessor): + SIBLING_TAGS = ['ul'] + + +class SemiSaneListExtension(markdown.Extension): + """An extension that causes lists to be treated the same way GitHub does. + + Like the sane_lists extension, GitHub considers a list to end if it's + separated by multiple newlines from another list of a different type. Unlike + the sane_lists extension, GitHub will mix list types if they're not + separated by multiple newlines. + + GitHub also recognizes lists that start in the middle of a paragraph. This + is currently not supported by this extension, since the Python parser has a + deeply-ingrained belief that blocks are always separated by multiple + newlines. + """ + + def extendMarkdown(self, md): + md.parser.blockprocessors.register(SemiSaneOListProcessor(md.parser), 'olist', 39) + md.parser.blockprocessors.register(SemiSaneUListProcessor(md.parser), 'ulist', 29) diff --git a/taiga/mdrender/extensions/spaced_link.py b/taiga/mdrender/extensions/spaced_link.py new file mode 100644 index 000000000..556217586 --- /dev/null +++ b/taiga/mdrender/extensions/spaced_link.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. + +import markdown + +BRK = markdown.inlinepatterns.BRK +NOIMG = markdown.inlinepatterns.NOIMG +SPACE = r"(?:\s*(?:\r\n|\r|\n)?\s*)" + +SPACED_LINK_RE = markdown.inlinepatterns.LINK_RE.replace( + NOIMG + BRK, NOIMG + BRK + SPACE) + +SPACED_REFERENCE_RE = markdown.inlinepatterns.REFERENCE_RE.replace( + NOIMG + BRK, NOIMG + BRK + SPACE) + +SPACED_IMAGE_LINK_RE = markdown.inlinepatterns.IMAGE_LINK_RE.replace( + r'\!' + BRK, r'\!' + BRK + SPACE) + +SPACED_IMAGE_REFERENCE_RE = markdown.inlinepatterns.IMAGE_REFERENCE_RE.replace( + r'\!' + BRK, r'\!' + BRK + SPACE) + + +class SpacedLinkExtension(markdown.Extension): + """An extension that supports links and images with additional whitespace. + + GitHub's Markdown engine allows links and images to have whitespace -- + including a single newline -- between the first set of brackets and the + second (e.g. ``[text] (href)``). Python-Markdown does not, but this + extension adds such support. + """ + + def extendMarkdown(self, md): + md.inlinePatterns["link"] = \ + markdown.inlinepatterns.LinkPattern(SPACED_LINK_RE, md) + md.inlinePatterns["reference"] = \ + markdown.inlinepatterns.ReferencePattern(SPACED_REFERENCE_RE, md) + md.inlinePatterns["image_link"] = \ + markdown.inlinepatterns.ImagePattern(SPACED_IMAGE_LINK_RE, md) + md.inlinePatterns["image_reference"] = \ + markdown.inlinepatterns.ImageReferencePattern( + SPACED_IMAGE_REFERENCE_RE, md) diff --git a/taiga/mdrender/extensions/strikethrough.py b/taiga/mdrender/extensions/strikethrough.py new file mode 100644 index 000000000..95865d6f8 --- /dev/null +++ b/taiga/mdrender/extensions/strikethrough.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. + +import markdown + +STRIKE_RE = r'(~{2})(.+?)(~{2})' # ~~strike~~ + + +class StrikethroughExtension(markdown.Extension): + """An extension that supports PHP-Markdown style strikethrough. + + For example: ``~~strike~~``. + """ + + def extendMarkdown(self, md): + pattern = markdown.inlinepatterns.SimpleTagPattern(STRIKE_RE, 'del') + md.inlinePatterns.register(pattern, 'strikethrough', 70) diff --git a/taiga/mdrender/extensions/target_link.py b/taiga/mdrender/extensions/target_link.py new file mode 100644 index 000000000..fc343b346 --- /dev/null +++ b/taiga/mdrender/extensions/target_link.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import re +import markdown + +from markdown.treeprocessors import Treeprocessor + +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.treeprocessors.register(TargetBlankLinksTreeprocessor(md), + "target_blank_links", + 10) + + +class TargetBlankLinksTreeprocessor(Treeprocessor): + def run(self, root): + home_url = resolve("home") + links = root.iter("a") + for a in links: + href = a.get("href", "") + url = a.get("href", "") + + if url.endswith("/"): + url = url[:-1] + + if not url.startswith(home_url): + a.set("target", "_blank") diff --git a/taiga/mdrender/extensions/wikilinks.py b/taiga/mdrender/extensions/wikilinks.py new file mode 100644 index 000000000..cb9560e2e --- /dev/null +++ b/taiga/mdrender/extensions/wikilinks.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from markdown import Extension +from markdown.inlinepatterns import Pattern +from markdown.treeprocessors import Treeprocessor +from xml.etree import ElementTree as etree + +from taiga.front.templatetags.functions import resolve +from taiga.base.utils.slug import slugify + +import re + + +class WikiLinkExtension(Extension): + def __init__(self, project, *args, **kwargs): + self.project = project + return super().__init__(*args, **kwargs) + + def extendMarkdown(self, md): + WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[^\]]+)?\]\]" + md.inlinePatterns.register(WikiLinksPattern(md, WIKILINK_RE, self.project), + "wikilinks", + 20) + md.treeprocessors.register(RelativeLinksTreeprocessor(md, self.project), + "relative_to_absolute_links", + 20) + + +class WikiLinksPattern(Pattern): + def __init__(self, md, pattern, project): + self.project = project + self.md = md + super().__init__(pattern) + + def handleMatch(self, m): + label = m.group(2).strip() + + # `project` could be other object (!) + slug = getattr(self.project, "slug", None) + if not slug: + project = getattr(self.project, "project", None) + slug = getattr(project, "slug", None) + if not slug: + return + + url = resolve("wiki", slug, slugify(label)) + + if m.group(3): + title = m.group(3).strip()[1:] + else: + title = label + + a = etree.Element("a") + a.text = title + a.set("href", url) + a.set("title", title) + a.set("class", "reference wiki") + return a + + +SLUG_RE = re.compile(r"^[-a-zA-Z0-9_]+$") + + +class RelativeLinksTreeprocessor(Treeprocessor): + def __init__(self, md, project): + self.project = project + super().__init__(md) + + def run(self, root): + links = root.iter("a") + for a in links: + href = a.get("href", "") + + if SLUG_RE.search(href): + # [wiki](wiki_page) -> ", ">").replace("\n", "
")) + + def _split_long_text(text, idx, size): + splited_text = text.split() + + if len(splited_text) > 25: + if idx == 0: + # The first is (...)text + first = "" + else: + first = " ".join(splited_text[:10]) + + if idx != 0 and idx == size - 1: + # The last is text(...) + last = "" + else: + last = " ".join(splited_text[-10:]) + + return "{}(...){}".format(first, last) + return text + + size = len(diffs) + html = [] + for idx, (op, data) in enumerate(diffs): + if op == self.DIFF_INSERT: + text = _sanitize_text(data) + html.append("{}".format(text)) + elif op == self.DIFF_DELETE: + text = _sanitize_text(data) + html.append("{}".format(text)) + elif op == self.DIFF_EQUAL: + text = _split_long_text(_sanitize_text(data), idx, size) + html.append("{}".format(text)) + + return "".join(html) + + +def get_diff_of_htmls(html1, html2): + diffutil = DiffMatchPatch() + diffs = diffutil.diff_main(html1 or "", html2 or "") + diffutil.diff_cleanupSemantic(diffs) + return diffutil.diff_pretty_html(diffs) + + +__all__ = ["render", "get_diff_of_htmls", "render_and_extract"] diff --git a/taiga/mdrender/templatetags/__init__.py b/taiga/mdrender/templatetags/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/mdrender/templatetags/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/mdrender/templatetags/functions.py b/taiga/mdrender/templatetags/functions.py new file mode 100644 index 000000000..844650687 --- /dev/null +++ b/taiga/mdrender/templatetags/functions.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django_jinja import library +from jinja2.utils import markupsafe +from taiga.mdrender.service import render + + +@library.global_function +def mdrender(project, text) -> str: + if text: + return markupsafe.Markup(render(project, text)) + return "" diff --git a/taiga/permissions/__init__.py b/taiga/permissions/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/permissions/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/permissions/choices.py b/taiga/permissions/choices.py new file mode 100644 index 000000000..8211d1072 --- /dev/null +++ b/taiga/permissions/choices.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext_lazy as _ + +ANON_PERMISSIONS = [ + ('view_project', _('View project')), + ('view_milestones', _('View milestones')), + ('view_epics', _('View epic')), + ('view_us', _('View user stories')), + ('view_tasks', _('View tasks')), + ('view_issues', _('View issues')), + ('view_wiki_pages', _('View wiki pages')), + ('view_wiki_links', _('View wiki links')), +] + +MEMBERS_PERMISSIONS = [ + ('view_project', _('View project')), + # Milestone permissions + ('view_milestones', _('View milestones')), + ('add_milestone', _('Add milestone')), + ('modify_milestone', _('Modify milestone')), + ('delete_milestone', _('Delete milestone')), + # Epic permissions + ('view_epics', _('View epic')), + ('add_epic', _('Add epic')), + ('modify_epic', _('Modify epic')), + ('comment_epic', _('Comment epic')), + ('delete_epic', _('Delete epic')), + # US permissions + ('view_us', _('View user story')), + ('add_us', _('Add user story')), + ('modify_us', _('Modify user story')), + ('comment_us', _('Comment user story')), + ('delete_us', _('Delete user story')), + # Task permissions + ('view_tasks', _('View tasks')), + ('add_task', _('Add task')), + ('modify_task', _('Modify task')), + ('comment_task', _('Comment task')), + ('delete_task', _('Delete task')), + # Issue permissions + ('view_issues', _('View issues')), + ('add_issue', _('Add issue')), + ('modify_issue', _('Modify issue')), + ('comment_issue', _('Comment issue')), + ('delete_issue', _('Delete issue')), + # Wiki page permissions + ('view_wiki_pages', _('View wiki pages')), + ('add_wiki_page', _('Add wiki page')), + ('modify_wiki_page', _('Modify wiki page')), + ('comment_wiki_page', _('Comment wiki page')), + ('delete_wiki_page', _('Delete wiki page')), + # Wiki link permissions + ('view_wiki_links', _('View wiki links')), + ('add_wiki_link', _('Add wiki link')), + ('modify_wiki_link', _('Modify wiki link')), + ('delete_wiki_link', _('Delete wiki link')), +] + +ADMINS_PERMISSIONS = [ + ('modify_project', _('Modify project')), + ('delete_project', _('Delete project')), + ('add_member', _('Add member')), + ('remove_member', _('Remove member')), + ('admin_project_values', _('Admin project values')), + ('admin_roles', _('Admin roles')), +] diff --git a/taiga/permissions/models.py b/taiga/permissions/models.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/permissions/models.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/permissions/permissions.py b/taiga/permissions/permissions.py new file mode 100644 index 000000000..fa2aefa58 --- /dev/null +++ b/taiga/permissions/permissions.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps + +from taiga.base.api.permissions import PermissionComponent + +from . import services + + +###################################################################### +# Generic perms +###################################################################### + +class HasProjectPerm(PermissionComponent): + def __init__(self, perm, *components): + self.project_perm = perm + super().__init__(*components) + + def check_permissions(self, request, view, obj=None): + return services.user_has_perm(request.user, self.project_perm, obj) + + +class IsObjectOwner(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if obj.owner is None: + return False + + return obj.owner == request.user + + +###################################################################### +# Project Perms +###################################################################### + +class IsProjectAdmin(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return services.is_project_admin(request.user, obj) + + +###################################################################### +# Common perms for stories, tasks and issues +###################################################################### + +class CommentAndOrUpdatePerm(PermissionComponent): + def __init__(self, update_perm, comment_perm, *components): + self.update_perm = update_perm + self.comment_perm = comment_perm + super().__init__(*components) + + def check_permissions(self, request, view, obj=None): + if not obj: + return False + + project_id = request.DATA.get('project', None) + if project_id and obj.project_id != project_id: + project = apps.get_model("projects", "Project").objects.get(pk=project_id) + else: + project = obj.project + + data_keys = set(request.DATA.keys()) - {"version"} + just_a_comment = data_keys == {"comment"} + + if (just_a_comment and services.user_has_perm(request.user, self.comment_perm, project)): + return True + + return services.user_has_perm(request.user, self.update_perm, project) diff --git a/taiga/permissions/services.py b/taiga/permissions/services.py new file mode 100644 index 000000000..c5bf0c3ec --- /dev/null +++ b/taiga/permissions/services.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from .choices import ADMINS_PERMISSIONS, MEMBERS_PERMISSIONS, ANON_PERMISSIONS + +from django.apps import apps + + +def _get_user_project_membership(user, project, cache="user"): + """ + cache param determines how memberships are calculated trying to reuse the existing data + in cache + """ + if user.is_anonymous: + return None + + if cache == "user": + return user.cached_membership_for_project(project) + + return project.cached_memberships_for_user(user) + + +def _get_object_project(obj): + project = None + Project = apps.get_model("projects", "Project") + if isinstance(obj, Project): + project = obj + elif obj and hasattr(obj, 'project'): + project = obj.project + return project + + +def is_project_owner(user, obj): + project = _get_object_project(obj) + if project is None: + return False + + if user.id == project.owner_id: + return True + + return False + + +def is_project_admin(user, obj): + if user.is_superuser: + return True + + project = _get_object_project(obj) + if project is None: + return False + + membership = _get_user_project_membership(user, project) + if membership and membership.is_admin: + return True + + return False + + +def user_has_perm(user, perm, obj=None, cache="user"): + """ + cache param determines how memberships are calculated trying to reuse the existing data + in cache + """ + project = _get_object_project(obj) + if not project: + return False + + return perm in get_user_project_permissions(user, project, cache=cache) + + +def _get_membership_permissions(membership): + if membership and membership.role and membership.role.permissions: + return membership.role.permissions + return [] + + +def calculate_permissions(is_authenticated=False, is_superuser=False, is_member=False, + is_admin=False, role_permissions=[], anon_permissions=[], + public_permissions=[]): + if is_superuser: + admins_permissions = list(map(lambda perm: perm[0], ADMINS_PERMISSIONS)) + members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS)) + public_permissions = [] + anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS)) + elif is_member: + if is_admin: + admins_permissions = list(map(lambda perm: perm[0], ADMINS_PERMISSIONS)) + members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS)) + else: + admins_permissions = [] + members_permissions = [] + members_permissions = members_permissions + role_permissions + public_permissions = public_permissions if public_permissions is not None else [] + anon_permissions = anon_permissions if anon_permissions is not None else [] + elif is_authenticated: + admins_permissions = [] + members_permissions = [] + public_permissions = public_permissions if public_permissions is not None else [] + anon_permissions = anon_permissions if anon_permissions is not None else [] + else: + admins_permissions = [] + members_permissions = [] + public_permissions = [] + anon_permissions = anon_permissions if anon_permissions is not None else [] + + return set(admins_permissions + members_permissions + public_permissions + anon_permissions) + + +def get_user_project_permissions(user, project, cache="user"): + """ + cache param determines how memberships are calculated trying to reuse the existing data + in cache + """ + membership = _get_user_project_membership(user, project, cache=cache) + is_member = membership is not None + is_admin = is_member and membership.is_admin + return calculate_permissions( + is_authenticated = user.is_authenticated, + is_superuser = user.is_superuser, + is_member = is_member, + is_admin = is_admin, + role_permissions = _get_membership_permissions(membership), + anon_permissions = project.anon_permissions, + public_permissions = project.public_permissions + ) + + +def set_base_permissions_for_project(project): + if project.is_private: + project.anon_permissions = [] + project.public_permissions = [] + else: + # If a project is public anonymous and registered users should have at + # least visualization permissions. + 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)) diff --git a/taiga/projects/__init__.py b/taiga/projects/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/admin.py b/taiga/projects/admin.py new file mode 100644 index 000000000..3a49e61bc --- /dev/null +++ b/taiga/projects/admin.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin +from django.urls import reverse +from django.db import transaction +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +from taiga.permissions.choices import ANON_PERMISSIONS +from taiga.projects.notifications.admin import NotifyPolicyInline +from taiga.projects.likes.admin import LikeInline +from taiga.users.admin import RoleInline + +from . import models + + +class MembershipAdmin(admin.ModelAdmin): + list_display = ['project', 'role', 'user'] + list_display_links = list_display + raw_id_fields = ["project"] + + def has_add_permission(self, request): + return False + + def get_object(self, *args, **kwargs): + self.obj = super().get_object(*args, **kwargs) + return self.obj + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name in ["user", "invited_by"] and getattr(self, 'obj', None): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.obj.project) + + elif db_field.name in ["role"] and getattr(self, 'obj', None): + kwargs["queryset"] = db_field.related_model.objects.filter( + project=self.obj.project) + + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +class MembershipInline(admin.TabularInline): + model = models.Membership + extra = 0 + + def get_formset(self, request, obj=None, **kwargs): + # Hack! Hook parent obj just in time to use in formfield_for_foreignkey + self.parent_obj = obj + return super(MembershipInline, self).get_formset(request, obj, **kwargs) + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if (db_field.name in ["user", "invited_by"]): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.parent_obj) + + elif (db_field.name in ["role"]): + kwargs["queryset"] = db_field.related_model.objects.filter( + project=self.parent_obj) + + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +class ProjectAdmin(admin.ModelAdmin): + list_display = ["id", "name", "slug", "is_private","owner_url", + "blocked_code", "is_featured", "created_date"] + list_display_links = ["id", "name", "slug"] + list_filter = ("is_private", "blocked_code", "is_featured", "created_date") + list_editable = ["is_featured", "blocked_code"] + search_fields = ["id", "name", "slug", "owner__username", "owner__email", "owner__full_name"] + inlines = [RoleInline, + MembershipInline, + NotifyPolicyInline, + LikeInline] + + fieldsets = ( + (None, { + "fields": ("name", + "slug", + "is_featured", + "description", + "tags", + "logo", + ("created_date", "modified_date")) + }), + (_("Privacy"), { + "fields": (("owner", "blocked_code"), + "is_private", + ("anon_permissions", "public_permissions"), + "transfer_token") + }), + (_("Extra info"), { + "classes": ("collapse",), + "fields": ("creation_template", + ("is_looking_for_people", "looking_for_people_note")), + }), + (_("Modules"), { + "classes": ("collapse",), + "fields": (("is_backlog_activated", "total_milestones", "total_story_points"), + "is_kanban_activated", + "is_issues_activated", + "is_wiki_activated", + ("videoconferences", "videoconferences_extra_data")), + }), + (_("Default values"), { + "classes": ("collapse",), + "fields": (("default_us_status", "default_points", "default_swimlane"), + "default_task_status", + "default_issue_status", + ("default_priority", "default_severity", "default_issue_type")), + }), + (_("Activity"), { + "classes": ("collapse",), + "fields": (("total_activity", "total_activity_last_week", + "total_activity_last_month", "total_activity_last_year"),), + }), + (_("Fans"), { + "classes": ("collapse",), + "fields": (("total_fans", "total_fans_last_week", + "total_fans_last_month", "total_fans_last_year"),), + }), + ) + + @admin.display( + description=_('owner') + ) + def owner_url(self, obj): + if obj.owner: + url = reverse('admin:{0}_{1}_change'.format(obj.owner._meta.app_label, + obj.owner._meta.model_name), + args=(obj.owner.pk,)) + return format_html("
{user}", url=url, user=obj.owner) + return "" + + def get_object(self, *args, **kwargs): + self.obj = super().get_object(*args, **kwargs) + return self.obj + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if (db_field.name in ["default_points", "default_us_status", "default_swimlane", "default_task_status", + "default_priority", "default_severity", + "default_issue_status", "default_issue_type"]): + if getattr(self, 'obj', None): + kwargs["queryset"] = db_field.related_model.objects.filter( + project=self.obj) + else: + kwargs["queryset"] = db_field.related_model.objects.none() + + elif (db_field.name in ["owner"] and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.obj.project) + + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def delete_model(self, request, obj): + obj.delete_related_content() + super().delete_model(request, obj) + + ## Actions + actions = [ + "make_public", + "make_private", + ] + + @admin.action( + description=_("Make public") + ) + @transaction.atomic + def make_public(self, request, queryset): + total_updates = 0 + + for project in queryset.exclude(is_private=False): + project.is_private = False + + 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() + total_updates += 1 + + self.message_user(request, _("{count} successfully made public.").format(count=total_updates)) + + @admin.action( + description=_("Make private") + ) + @transaction.atomic + def make_private(self, request, queryset): + total_updates = 0 + + for project in queryset.exclude(is_private=True): + project.is_private = True + project.anon_permissions = [] + project.public_permissions = [] + + project.save() + total_updates += 1 + + self.message_user(request, _("{count} successfully made private.").format(count=total_updates)) + + def delete_queryset(self, request, queryset): + # NOTE: Override delete_queryset so its use the same approach used in + # taiga.projects.models.Project.delete_related_content. + # + # More info https://docs.djangoproject.com/en/2.2/ref/contrib/admin/actions/#admin-actions + + from taiga.events.apps import (connect_events_signals, + disconnect_events_signals) + from taiga.projects.tasks.apps import (connect_all_tasks_signals, + disconnect_all_tasks_signals) + from taiga.projects.userstories.apps import (connect_all_userstories_signals, + disconnect_all_userstories_signals) + from taiga.projects.issues.apps import (connect_all_issues_signals, + disconnect_all_issues_signals) + from taiga.projects.apps import (connect_memberships_signals, + disconnect_memberships_signals) + + disconnect_events_signals() + disconnect_all_issues_signals() + disconnect_all_tasks_signals() + disconnect_all_userstories_signals() + disconnect_memberships_signals() + + try: + super().delete_queryset(request, queryset) + finally: + connect_events_signals() + connect_all_issues_signals() + connect_all_tasks_signals() + connect_all_userstories_signals() + connect_memberships_signals() + +# User Stories common admins +class PointsAdmin(admin.ModelAdmin): + list_display = ["project", "order", "name", "value"] + list_display_links = ["name"] + raw_id_fields = ["project"] + + +class UserStoryStatusAdmin(admin.ModelAdmin): + list_display = ["project", "order", "name", "is_closed"] + list_display_links = ["name"] + raw_id_fields = ["project"] + + +class EpicStatusAdmin(admin.ModelAdmin): + list_display = ["project", "order", "name", "is_closed"] + list_display_links = ["name"] + raw_id_fields = ["project"] + + +class SwimlaneAdmin(admin.ModelAdmin): + list_display = ["project", "name", "order"] + list_display_links = ["project", "name"] + raw_id_fields = ["project"] + search_fields = ["project", "name"] + + +class SwimlaneUserStoryStatusAdmin(admin.ModelAdmin): + list_display = ["project", "swimlane", "status", "wip_limit"] + list_display_links = ["swimlane", "status"] + + +# Tasks common admins + +class TaskStatusAdmin(admin.ModelAdmin): + list_display = ["project", "order", "name", "is_closed", "color"] + list_display_links = ["name"] + raw_id_fields = ["project"] + + +# Issues common admins + +class SeverityAdmin(admin.ModelAdmin): + list_display = ["project", "order", "name", "color"] + list_display_links = ["name"] + raw_id_fields = ["project"] + + +class PriorityAdmin(admin.ModelAdmin): + list_display = ["project", "order", "name", "color"] + list_display_links = ["name"] + raw_id_fields = ["project"] + + +class IssueTypeAdmin(admin.ModelAdmin): + list_display = ["project", "order", "name", "color"] + list_display_links = ["name"] + raw_id_fields = ["project"] + + +class IssueStatusAdmin(admin.ModelAdmin): + list_display = ["project", "order", "name", "is_closed", "color"] + list_display_links = ["name"] + raw_id_fields = ["project"] + + +class ProjectTemplateAdmin(admin.ModelAdmin): + pass + + +admin.site.register(models.IssueStatus, IssueStatusAdmin) +admin.site.register(models.TaskStatus, TaskStatusAdmin) +admin.site.register(models.UserStoryStatus, UserStoryStatusAdmin) +admin.site.register(models.EpicStatus, EpicStatusAdmin) +admin.site.register(models.Points, PointsAdmin) +admin.site.register(models.Swimlane, SwimlaneAdmin) +admin.site.register(models.SwimlaneUserStoryStatus, SwimlaneUserStoryStatusAdmin) +admin.site.register(models.Project, ProjectAdmin) +admin.site.register(models.Membership, MembershipAdmin) +admin.site.register(models.Severity, SeverityAdmin) +admin.site.register(models.Priority, PriorityAdmin) +admin.site.register(models.IssueType, IssueTypeAdmin) +admin.site.register(models.ProjectTemplate, ProjectTemplateAdmin) diff --git a/taiga/projects/api.py b/taiga/projects/api.py new file mode 100644 index 000000000..958cf06f6 --- /dev/null +++ b/taiga/projects/api.py @@ -0,0 +1,1171 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid +import functools +from easy_thumbnails.source_generators import pil_image +from dateutil.relativedelta import relativedelta + +from django.apps import apps +from django.conf import settings +from django.http import Http404 +from django.utils.translation import gettext as _ +from django.utils import timezone + +from django_pglocks import advisory_lock + +from taiga.base import filters +from taiga.base import exceptions as exc +from taiga.base import response +from taiga.base.api import ModelCrudViewSet, ModelListViewSet, ModelUpdateRetrieveViewSet +from taiga.base.api.mixins import BlockedByProjectMixin, BlockeableSaveMixin, BlockeableDeleteMixin +from taiga.base.api.permissions import AllowAnyPermission +from taiga.base.api.utils import get_object_or_error +from taiga.base.api.viewsets import ViewSet +from taiga.base.decorators import list_route +from taiga.base.decorators import detail_route +from taiga.base.utils.slug import slugify_uniquely + +from taiga.permissions import services as permissions_services + +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, MoveOnDestroySwimlaneMixin +from taiga.projects.mixins.ordering import BulkUpdateOrderMixin +from taiga.projects.signals import issue_status_post_move_on_destroy as issue_status_post_move_on_destroy_signal +from taiga.projects.tasks.models import Task +from taiga.projects.tagging.api import TagsColorsResourceMixin +from taiga.projects.userstories.models import UserStory, RolePoints +from taiga.projects.userstories.services import reset_userstories_kanban_order_in_bulk + +from . import filters as project_filters +from . import models +from . import permissions +from . import serializers +from . import validators +from . import services +from . import utils as project_utils +from . import throttling + +###################################################### +# Project +###################################################### +from ..base.exceptions import NotAuthenticated + + +class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, + BlockeableSaveMixin, BlockeableDeleteMixin, + TagsColorsResourceMixin, ModelCrudViewSet): + validator_class = validators.ProjectValidator + queryset = models.Project.objects.all() + permission_classes = (permissions.ProjectPermission, ) + filter_backends = (project_filters.UserOrderFilterBackend, + project_filters.QFilterBackend, + project_filters.CanViewProjectObjFilterBackend, + project_filters.DiscoverModeFilterBackend) + + filter_fields = (("member", "members"), + "is_looking_for_people", + "is_featured", + "is_backlog_activated", + "is_kanban_activated") + + ordering = ("name", "id") + order_by_fields = ("total_fans", + "total_fans_last_week", + "total_fans_last_month", + "total_fans_last_year", + "total_activity", + "total_activity_last_week", + "total_activity_last_month", + "total_activity_last_year") + + def is_blocked(self, obj): + return obj.blocked_code is not None + + def _get_order_by_field_name(self): + order_by_query_param = project_filters.CanViewProjectObjFilterBackend.order_by_query_param + order_by = self.request.QUERY_PARAMS.get(order_by_query_param, None) + if order_by is not None and order_by.startswith("-"): + return order_by[1:] + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.select_related("owner") + if self.request.QUERY_PARAMS.get('discover_mode', False): + qs = project_utils.attach_members(qs) + 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_closed_milestones(qs) + qs = project_utils.attach_my_homepage(qs, user=self.request.user) + elif self.request.QUERY_PARAMS.get('slight', False): + qs = project_utils.attach_basic_info(qs, user=self.request.user) + else: + qs = project_utils.attach_extra_info(qs, user=self.request.user) + + # If filtering an activity period we must exclude the activities not updated recently enough + now = timezone.now() + order_by_field_name = self._get_order_by_field_name() + if order_by_field_name == "total_fans_last_week": + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(weeks=1)) + elif order_by_field_name == "total_fans_last_month": + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(months=1)) + elif order_by_field_name == "total_fans_last_year": + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(years=1)) + elif order_by_field_name == "total_activity_last_week": + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(weeks=1)) + elif order_by_field_name == "total_activity_last_month": + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(months=1)) + elif order_by_field_name == "total_activity_last_year": + qs = qs.filter(totals_updated_datetime__gte=now - relativedelta(years=1)) + + return qs + + def retrieve(self, request, *args, **kwargs): + qs = self.get_queryset() + if self.action == "by_slug": + self.lookup_field = "slug" + # If we retrieve the project by slug we want to filter by user the + # permissions and return 404 in case the user don't have access + flt = filters.get_filter_expression_can_view_projects(request.user) + + qs = qs.filter(flt) + + self.object = get_object_or_error(qs, request.user, **kwargs) + + self.check_permissions(request, 'retrieve', self.object) + + if self.object is None: + raise Http404 + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + def get_serializer_class(self): + if self.action == "list" and self.request.QUERY_PARAMS.get('slight', False): + return serializers.ProjectLightSerializer + if self.action == "list": + return serializers.ProjectSerializer + + return serializers.ProjectDetailSerializer + + @detail_route(methods=["POST"]) + def change_logo(self, request, *args, **kwargs): + """ + Change logo to this project. + """ + self.object = get_object_or_error(self.get_queryset(), request.user, **kwargs) + self.check_permissions(request, "change_logo", self.object) + + logo = request.FILES.get('logo', None) + if not logo: + raise exc.WrongArguments(_("Incomplete arguments")) + try: + pil_image(logo) + except Exception: + raise exc.WrongArguments(_("Invalid image format")) + + self.pre_conditions_on_save(self.object) + + self.object.logo = logo + self.object.save(update_fields=["logo"]) + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + @detail_route(methods=["POST"]) + def remove_logo(self, request, *args, **kwargs): + """ + Remove the logo of a project. + """ + self.object = get_object_or_error(self.get_queryset(), request.user, **kwargs) + self.check_permissions(request, "remove_logo", self.object) + self.pre_conditions_on_save(self.object) + self.object.logo = None + self.object.save(update_fields=["logo"]) + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + @detail_route(methods=["POST"]) + def watch(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "watch", project) + self.pre_conditions_on_save(project) + notify_level = request.DATA.get("notify_level", NotifyLevel.involved) + project.add_watcher(self.request.user, notify_level=notify_level) + return response.Ok() + + @detail_route(methods=["POST"]) + def unwatch(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "unwatch", project) + self.pre_conditions_on_save(project) + user = self.request.user + project.remove_watcher(user) + return response.Ok() + + @list_route(methods=["POST"]) + def bulk_update_order(self, request, **kwargs): + if self.request.user.is_anonymous: + return response.Unauthorized() + + validator = validators.UpdateProjectOrderBulkValidator(data=request.DATA, many=True) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + services.update_projects_order_in_bulk(data, "user_order", request.user) + return response.NoContent(data=None) + + @detail_route(methods=["POST"]) + def create_template(self, request, **kwargs): + template_name = request.DATA.get('template_name', None) + template_description = request.DATA.get('template_description', None) + + if not template_name: + raise response.BadRequest(_("Invalid template name")) + + if not template_description: + raise response.BadRequest(_("Invalid template description")) + + with advisory_lock("create-project-template"): + template_slug = slugify_uniquely(template_name, models.ProjectTemplate) + + project = self.get_object() + + self.check_permissions(request, 'create_template', project) + + template = models.ProjectTemplate( + name=template_name, + slug=template_slug, + description=template_description, + ) + + template.load_data_from_project(project) + + template.save() + return response.Created(serializers.ProjectTemplateSerializer(template).data) + + @detail_route(methods=['POST']) + def leave(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, 'leave', project) + self.pre_conditions_on_save(project) + services.remove_user_from_project(request.user, project) + return response.Ok() + + def _regenerate_csv_uuid(self, project, field): + uuid_value = uuid.uuid4().hex + setattr(project, field, uuid_value) + project.save() + return uuid_value + + def _delete_csv_uuid(self, project, field): + setattr(project, field, None) + project.save() + return getattr(project, field) + + @detail_route(methods=["POST"]) + def regenerate_epics_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "regenerate_epics_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._regenerate_csv_uuid(project, "epics_csv_uuid")} + return response.Ok(data) + + @detail_route(methods=["POST"]) + def regenerate_userstories_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "regenerate_userstories_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._regenerate_csv_uuid(project, "userstories_csv_uuid")} + return response.Ok(data) + + @detail_route(methods=["POST"]) + def regenerate_tasks_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "regenerate_tasks_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._regenerate_csv_uuid(project, "tasks_csv_uuid")} + return response.Ok(data) + + @detail_route(methods=["POST"]) + def regenerate_issues_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "regenerate_issues_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._regenerate_csv_uuid(project, "issues_csv_uuid")} + return response.Ok(data) + + @detail_route(methods=["POST"]) + def delete_epics_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "delete_epics_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._delete_csv_uuid(project, "epics_csv_uuid")} + return response.Ok(data) + + @detail_route(methods=["POST"]) + def delete_userstories_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "delete_userstories_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._delete_csv_uuid(project, "userstories_csv_uuid")} + return response.Ok(data) + + @detail_route(methods=["POST"]) + def delete_tasks_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "delete_tasks_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._delete_csv_uuid(project, "tasks_csv_uuid")} + return response.Ok(data) + + @detail_route(methods=["POST"]) + def delete_issues_csv_uuid(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "delete_issues_csv_uuid", project) + self.pre_conditions_on_save(project) + data = {"uuid": self._delete_csv_uuid(project, "issues_csv_uuid")} + return response.Ok(data) + + @list_route(methods=["GET"]) + def by_slug(self, request, *args, **kwargs): + slug = request.QUERY_PARAMS.get("slug", None) + + return self.retrieve(request, slug=slug) + + @detail_route(methods=["GET", "PATCH"]) + def modules(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, 'modules', project) + modules_config = services.get_modules_config(project) + + if request.method == "GET": + return response.Ok(modules_config.config) + + else: + self.pre_conditions_on_save(project) + modules_config.config.update(request.DATA) + modules_config.save() + return response.NoContent() + + @detail_route(methods=["GET"]) + def stats(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "stats", project) + return response.Ok(services.get_stats_for_project(project)) + + @detail_route(methods=["GET"]) + def member_stats(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "member_stats", project) + return response.Ok(services.get_member_stats_for_project(project)) + + @detail_route(methods=["GET"]) + def issues_stats(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "issues_stats", project) + return response.Ok(services.get_stats_for_project_issues(project)) + + @detail_route(methods=["POST"]) + def transfer_validate_token(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "transfer_validate_token", project) + token = request.DATA.get('token', None) + services.transfer.validate_project_transfer_token(token, project, request.user) + return response.Ok() + + @detail_route(methods=["POST"]) + def transfer_request(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "transfer_request", project) + services.request_project_transfer(project, request.user) + return response.Ok() + + @detail_route(methods=['post']) + def transfer_start(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "transfer_start", project) + + user_id = request.DATA.get('user', None) + if user_id is None: + raise exc.WrongArguments(_("Invalid user id")) + + user_model = apps.get_model("users", "User") + try: + user = user_model.objects.get(id=user_id) + except user_model.DoesNotExist: + return response.BadRequest(_("The user doesn't exist")) + + # Check the user is a membership from the project + if not project.memberships.filter(user=user).exists(): + return response.BadRequest(_("The user must already be a project member")) + + reason = request.DATA.get('reason', None) + services.start_project_transfer(project, user, reason) + return response.Ok() + + @detail_route(methods=["POST"]) + def transfer_accept(self, request, pk=None): + token = request.DATA.get('token', None) + if token is None: + raise exc.WrongArguments(_("Invalid token")) + + project = self.get_object() + self.check_permissions(request, "transfer_accept", project) + + (can_transfer, error_message, total_members) = services.check_if_project_can_be_transfered( + project, + request.user, + ) + if not can_transfer: + raise exc.NotEnoughSlotsForProject(project.is_private, total_members, error_message) + + reason = request.DATA.get('reason', None) + services.accept_project_transfer(project, request.user, token, reason) + return response.Ok() + + @detail_route(methods=["POST"]) + def transfer_reject(self, request, pk=None): + token = request.DATA.get('token', None) + if token is None: + raise exc.WrongArguments(_("Invalid token")) + + project = self.get_object() + self.check_permissions(request, "transfer_reject", project) + + reason = request.DATA.get('reason', None) + services.reject_project_transfer(project, request.user, token, reason) + return response.Ok() + + @detail_route(methods=["POST"]) + def duplicate(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "duplicate", project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + validator = validators.DuplicateProjectValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + new_name = data.get("name", "") + new_description = data.get("description", "") + new_owner = self.request.user + new_is_private = data.get('is_private', False) + new_members = data.get("users", []) + + # Validate if the project can be imported + (enough_slots, error_message, total_members) = services.check_if_project_can_be_duplicate( + project=project, + new_owner=new_owner, + new_is_private=new_is_private, + new_user_id_members=[m["id"] for m in new_members] + ) + if not enough_slots: + raise exc.NotEnoughSlotsForProject(new_is_private, total_members, error_message) + + new_project = services.duplicate_project( + project=project, + name=new_name, + description=new_description, + owner=new_owner, + is_private=new_is_private, + users=new_members + ) + new_project = get_object_or_error(self.get_queryset(), request.user, id=new_project.id) + serializer = self.get_serializer(new_project) + return response.Created(serializer.data) + + def _raise_if_blocked(self, project): + if self.is_blocked(project): + raise exc.Blocked(_("Blocked element")) + + def _set_base_permissions(self, obj): + update_permissions = False + if not obj.id: + if not obj.is_private: + # Creating a public project + update_permissions = True + else: + if self.get_object().is_private != obj.is_private: + # Changing project public state + update_permissions = True + + if update_permissions: + permissions_services.set_base_permissions_for_project(obj) + + def pre_save(self, obj): + if not obj.id: + obj.owner = self.request.user + obj.template = self.request.QUERY_PARAMS.get('template', None) + + if not obj.id or self.get_object().is_private != obj.is_private: + # Validate if the owner have enough slots to create the project + # or if you are changing the privacy + (can_create_or_update, error_message, total_members) = services.check_if_project_can_be_created_or_updated(obj) + if not can_create_or_update: + raise exc.NotEnoughSlotsForProject(obj.is_private, total_members or 1, error_message) + + self._set_base_permissions(obj) + super().pre_save(obj) + + def destroy(self, request, *args, **kwargs): + obj = self.get_object_or_none() + self.check_permissions(request, 'destroy', obj) + + if obj is None: + raise Http404 + + self.pre_delete(obj) + self.pre_conditions_on_delete(obj) + + services.orphan_project(obj) + if settings.CELERY_ENABLED: + services.delete_project.delay(obj.id) + else: + services.delete_project(obj.id) + + return response.NoContent() + + +class DeleteOwnProjectsViewSet(ViewSet): + def create(self, request, *args, **kwargs): + projects = models.Project.objects.filter(owner=request.user, is_private=True) + for project in projects: + services.orphan_project(project) + + if settings.CELERY_ENABLED: + services.delete_projects.delay(projects) + else: + services.delete_projects(projects) + + return response.NoContent() + + +class ProjectFansViewSet(FansViewSetMixin, ModelListViewSet): + permission_classes = (permissions.ProjectFansPermission,) + resource_model = models.Project + + +class ProjectWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.ProjectWatchersPermission,) + resource_model = models.Project + + +###################################################### +## Custom values for selectors +###################################################### + +class EpicStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + + model = models.EpicStatus + serializer_class = serializers.EpicStatusSerializer + validator_class = validators.EpicStatusValidator + permission_classes = (permissions.EpicStatusPermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + bulk_update_param = "bulk_epic_statuses" + bulk_update_perm = "change_epicstatus" + bulk_update_order_action = services.bulk_update_epic_status_order + move_on_destroy_related_class = Epic + move_on_destroy_related_field = "status" + move_on_destroy_project_default_field = "default_epic_status" + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("epic-status-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + +class UserStoryStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + + model = models.UserStoryStatus + serializer_class = serializers.UserStoryStatusSerializer + validator_class = validators.UserStoryStatusValidator + permission_classes = (permissions.UserStoryStatusPermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + bulk_update_param = "bulk_userstory_statuses" + bulk_update_perm = "change_userstorystatus" + bulk_update_order_action = services.bulk_update_userstory_status_order + move_on_destroy_related_class = UserStory + move_on_destroy_related_field = "status" + move_on_destroy_project_default_field = "default_us_status" + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("epic-user-story-status-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + def move_on_destroy_reorder_after_moved(self, moved_to_obj, moved_objs_queryset): + project = moved_to_obj.project + bulk_userstories_ids = (moved_objs_queryset.order_by('swimlane__order', 'kanban_order') + .values_list('id', flat=True)) + reset_userstories_kanban_order_in_bulk(project, bulk_userstories_ids) + + +class PointsViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + + model = models.Points + serializer_class = serializers.PointsSerializer + validator_class = validators.PointsValidator + permission_classes = (permissions.PointsPermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + bulk_update_param = "bulk_points" + bulk_update_perm = "change_points" + bulk_update_order_action = services.bulk_update_points_order + move_on_destroy_related_class = RolePoints + move_on_destroy_related_field = "points" + move_on_destroy_project_default_field = "default_points" + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("points-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + +class SwimlaneViewSet(MoveOnDestroySwimlaneMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + + model = models.Swimlane + serializer_class = serializers.SwimlaneSerializer + validator_class = validators.SwimlaneValidator + permission_classes = (permissions.SwimlanePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + bulk_update_param = "bulk_swimlanes" + bulk_update_perm = "change_swimlanes" + bulk_update_order_action = services.bulk_update_swimlane_order + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("swimlane-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + def post_save(self, object, created=False): + super().post_save(object, created=created) + + if not created: + return + + # If it's a creation and it's the first and only swimlane, + # then assign all Userstories to this new swimlane + total_swimlanes = object.project.swimlanes.count() + if total_swimlanes == 1: + uss = object.project.user_stories.all() + uss.update(swimlane=object) + + def pre_delete(self, obj): + if obj.project.default_swimlane_id == obj.id and obj.project.swimlanes.count() > 1: + raise exc.BadRequest(_("The default swimlane cannot be deleted.")) + + +class SwimlaneUserStoryStatusViewSet(BlockedByProjectMixin, ModelListViewSet, ModelUpdateRetrieveViewSet): + model = models.SwimlaneUserStoryStatus + serializer_class = serializers.SwimlaneUserStoryStatusSerializer + validator_class = validators.SwimlaneUserStoryStatusValidator + permission_classes = (permissions.SwimlaneUserStoryStatusPermission,) + filter_backends = (filters.custom_filter_class(filters.CanViewProjectFilterBackend, + project_query_param="status__project"),) + filter_fields = ('project',) + + +class UserStoryDueDateViewSet(BlockedByProjectMixin, ModelCrudViewSet): + + model = models.UserStoryDueDate + serializer_class = serializers.UserStoryDueDateSerializer + validator_class = validators.UserStoryDueDateValidator + permission_classes = (permissions.UserStoryDueDatePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("user-story-due-date-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + def pre_delete(self, obj): + if obj.by_default: + raise exc.BadRequest( + _("You can't delete the default due date status of a user story")) + + @list_route(methods=["POST"]) + def create_default(self, request, **kwargs): + context = { + "request": request + } + validator = validators.DueDatesCreationValidator(data=request.DATA, + context=context) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + project_id = request.DATA.get('project_id') + project = models.Project.objects.get(id=project_id) + + if project.us_duedates.all(): + raise exc.BadRequest(_("Project does already have due dates")) + + project_template = models.ProjectTemplate.objects.get( + id=project.creation_template.id) + + for us_duedate in project_template.us_duedates: + models.UserStoryDueDate.objects.create( + name=us_duedate["name"], + by_default=us_duedate["by_default"], + color=us_duedate["color"], + days_to_due=us_duedate["days_to_due"], + order=us_duedate["order"], + project=project + ) + project.save() + + serializer = self.get_serializer(project.us_duedates.all(), many=True) + + return response.Ok(serializer.data) + + +class TaskStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + + model = models.TaskStatus + serializer_class = serializers.TaskStatusSerializer + validator_class = validators.TaskStatusValidator + permission_classes = (permissions.TaskStatusPermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_task_statuses" + bulk_update_perm = "change_taskstatus" + bulk_update_order_action = services.bulk_update_task_status_order + move_on_destroy_related_class = Task + move_on_destroy_related_field = "status" + move_on_destroy_project_default_field = "default_task_status" + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("task-status-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + +class TaskDueDateViewSet(BlockedByProjectMixin, ModelCrudViewSet): + + model = models.TaskDueDate + serializer_class = serializers.TaskDueDateSerializer + validator_class = validators.TaskDueDateValidator + permission_classes = (permissions.TaskDueDatePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("task-due-date-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + def pre_delete(self, obj): + if obj.by_default: + raise exc.BadRequest( + _("You can't delete the default due date status of a task")) + + @list_route(methods=["POST"]) + def create_default(self, request, **kwargs): + context = { + "request": request + } + validator = validators.DueDatesCreationValidator(data=request.DATA, + context=context) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + project_id = request.DATA.get('project_id') + project = models.Project.objects.get(id=project_id) + + if project.task_duedates.all(): + raise exc.BadRequest(_("Project does already have task due dates")) + + project_template = models.ProjectTemplate.objects.get( + id=project.creation_template.id) + + for task_duedate in project_template.task_duedates: + models.TaskDueDate.objects.create( + name=task_duedate["name"], + by_default=task_duedate["by_default"], + color=task_duedate["color"], + days_to_due=task_duedate["days_to_due"], + order=task_duedate["order"], + project=project + ) + project.save() + + serializer = self.get_serializer(project.task_duedates.all(), + many=True) + + return response.Ok(serializer.data) + + +class SeverityViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + + model = models.Severity + serializer_class = serializers.SeveritySerializer + validator_class = validators.SeverityValidator + permission_classes = (permissions.SeverityPermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_severities" + bulk_update_perm = "change_severity" + bulk_update_order_action = services.bulk_update_severity_order + move_on_destroy_related_class = Issue + move_on_destroy_related_field = "severity" + move_on_destroy_project_default_field = "default_severity" + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("severity-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + +class PriorityViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + model = models.Priority + serializer_class = serializers.PrioritySerializer + validator_class = validators.PriorityValidator + permission_classes = (permissions.PriorityPermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_priorities" + bulk_update_perm = "change_priority" + bulk_update_order_action = services.bulk_update_priority_order + move_on_destroy_related_class = Issue + move_on_destroy_related_field = "priority" + move_on_destroy_project_default_field = "default_priority" + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("priority-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + +class IssueTypeViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + model = models.IssueType + serializer_class = serializers.IssueTypeSerializer + validator_class = validators.IssueTypeValidator + permission_classes = (permissions.IssueTypePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_issue_types" + bulk_update_perm = "change_issuetype" + bulk_update_order_action = services.bulk_update_issue_type_order + move_on_destroy_related_class = Issue + move_on_destroy_related_field = "type" + move_on_destroy_project_default_field = "default_issue_type" + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("issue-type-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + +class IssueStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, + ModelCrudViewSet, BulkUpdateOrderMixin): + model = models.IssueStatus + serializer_class = serializers.IssueStatusSerializer + validator_class = validators.IssueStatusValidator + permission_classes = (permissions.IssueStatusPermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_issue_statuses" + bulk_update_perm = "change_issuestatus" + bulk_update_order_action = services.bulk_update_issue_status_order + move_on_destroy_related_class = Issue + move_on_destroy_related_field = "status" + move_on_destroy_project_default_field = "default_issue_status" + move_on_destroy_post_destroy_signal = issue_status_post_move_on_destroy_signal + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("issue-status-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + +class IssueDueDateViewSet(BlockedByProjectMixin, ModelCrudViewSet): + + model = models.IssueDueDate + serializer_class = serializers.IssueDueDateSerializer + validator_class = validators.IssueDueDateValidator + permission_classes = (permissions.IssueDueDatePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("issue-due-date-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + def pre_delete(self, obj): + if obj.by_default: + raise exc.BadRequest( + _("You can't delete the default due date status of an issue")) + + @list_route(methods=["POST"]) + def create_default(self, request, **kwargs): + context = { + "request": request + } + validator = validators.DueDatesCreationValidator(data=request.DATA, + context=context) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + project_id = request.DATA.get('project_id') + project = models.Project.objects.get(id=project_id) + + if project.issue_duedates.all(): + raise exc.BadRequest(_("Project does already have issue due dates")) + + project_template = models.ProjectTemplate.objects.get( + id=project.creation_template.id) + + for issue_duedate in project_template.issue_duedates: + models.IssueDueDate.objects.create( + name=issue_duedate["name"], + by_default=issue_duedate["by_default"], + color=issue_duedate["color"], + days_to_due=issue_duedate["days_to_due"], + order=issue_duedate["order"], + project=project + ) + project.save() + + serializer = self.get_serializer(project.issue_duedates.all(), + many=True) + + return response.Ok(serializer.data) + + +###################################################### +## Project Template +###################################################### + +class ProjectTemplateViewSet(ModelCrudViewSet): + model = models.ProjectTemplate + serializer_class = serializers.ProjectTemplateSerializer + validator_class = validators.ProjectTemplateValidator + permission_classes = (permissions.ProjectTemplatePermission,) + + def get_queryset(self): + return models.ProjectTemplate.objects.all() + + +###################################################### +## Members & Invitations +###################################################### + +class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet): + model = models.Membership + admin_serializer_class = serializers.MembershipAdminSerializer + serializer_class = serializers.MembershipSerializer + validator_class = validators.MembershipValidator + permission_classes = (permissions.MembershipPermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project", "role") + throttle_classes = (throttling.MembershipsRateThrottle,) + + def get_serializer_class(self): + use_admin_serializer = False + + if self.action == "create": + use_admin_serializer = True + + if self.action == "retrieve": + use_admin_serializer = permissions_services.is_project_admin(self.request.user, self.object.project) + + project_id = self.request.QUERY_PARAMS.get("project", None) + if self.action == "list" and project_id is not None: + project = get_object_or_error(models.Project, self.request.user, pk=project_id) + use_admin_serializer = permissions_services.is_project_admin(self.request.user, project) + + if use_admin_serializer: + return self.admin_serializer_class + + else: + return self.serializer_class + + def _check_if_new_member_can_be_created(self, membership): + (can_add_memberships, error_type, total_memberships) = services.check_if_new_member_can_be_created( + membership + ) + if not can_add_memberships: + raise exc.NotEnoughSlotsForProject( + membership.project.is_private, + total_memberships, + error_type + ) + + def _check_if_new_members_can_be_created(self, project, memberships): + (can_add_memberships, error_type, total_memberships) = services.check_if_new_members_can_be_created( + project, + memberships + ) + if not can_add_memberships: + raise exc.NotEnoughSlotsForProject( + project.is_private, + total_memberships, + error_type + ) + + @list_route(methods=["POST"]) + def bulk_create(self, request, **kwargs): + context = { + "request": request + } + validator = validators.MembersBulkValidator(data=request.DATA, context=context) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + + project = models.Project.objects.get(id=data["project_id"]) + self.check_permissions(request, 'bulk_create', project) + + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + if not request.user.verified_email: + return response.BadRequest({ + "_error_message": _("To add members to a project, first you have to verify your email address") + }) + + bulk_memberships = data["bulk_memberships"] + invitation_extra_text = data.get("invitation_extra_text", None) + + try: + with advisory_lock("membership-creation-{}".format(project.id)): + members = services.get_members_from_bulk(bulk_memberships, + project=project, + invited_by=request.user, + invitation_extra_text=invitation_extra_text) + self._check_if_new_members_can_be_created(project, members) + services.create_members_in_bulk(members, callback=self.post_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) + + members_serialized = self.admin_serializer_class(members, many=True) + return response.Ok(members_serialized.data) + + @detail_route(methods=["POST"]) + def resend_invitation(self, request, **kwargs): + invitation = self.get_object() + + self.check_permissions(request, 'resend_invitation', invitation.project) + self.pre_conditions_on_save(invitation) + + services.send_invitation(invitation=invitation) + return response.NoContent() + + @list_route(methods=["POST"]) + def remove_user_from_all_my_projects(self, request, **kwargs): + private_only = request.DATA.get('private_only', False) + + user_id = request.DATA.get('user', None) + if user_id is None: + raise exc.WrongArguments(_("Invalid user id")) + + user_model = apps.get_model("users", "User") + try: + user = user_model.objects.get(id=user_id) + except user_model.DoesNotExist: + return response.BadRequest(_("The user doesn't exist")) + + memberships = models.Membership.objects.filter(project__owner=request.user, user=user) + if private_only: + memberships = memberships.filter(project__is_private=True) + + errors = [] + for membership in memberships: + if not services.can_user_leave_project(user, membership.project): + errors.append(membership.project.name) + + if len(errors) > 0: + error = _("This user can't be removed from the following projects, because that would " + "leave them without any active admin: {}.".format(", ".join(errors))) + return response.BadRequest(error) + + memberships.delete() + + return response.NoContent() + + @list_route(methods=["POST"]) + def remove_invitation_from_all_my_projects(self, request, **kwargs): + private_only = request.DATA.get('private_only', False) + + email = request.DATA.get('email', None) + if email is None: + raise exc.WrongArguments(_("Email is required")) + + memberships = models.Membership.objects.filter(project__owner=request.user, email=email, user__isnull=True) + if private_only: + memberships = memberships.filter(project__is_private=True) + memberships.delete() + + return response.NoContent() + + def pre_delete(self, obj): + if obj.user is not None and not services.can_user_leave_project(obj.user, obj.project): + raise exc.BadRequest(_("The project must have an owner and at least one of the users " + "must be an active admin")) + + def pre_save(self, obj): + if not obj.id: + self._check_if_new_member_can_be_created(obj) + + obj.invited_by = self.request.user + obj.user = services.find_invited_user(obj.email, default=obj.user) + + if not obj.token and not obj.user: + obj.token = str(uuid.uuid1()) + + super().pre_save(obj) + + def post_save(self, object, created=False): + super().post_save(object, created=created) + + if not created: + return + + # Send email only if a new membership is created + services.send_invitation(invitation=object) + + +class InvitationViewSet(ModelListViewSet): + """ + Only used by front for get invitation by it token. + """ + queryset = models.Membership.objects.filter(user__isnull=True) + serializer_class = serializers.MembershipSerializer + lookup_field = "token" + permission_classes = (AllowAnyPermission,) + + def list(self, *args, **kwargs): + raise exc.PermissionDenied(_("You don't have permissions to see that.")) diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py new file mode 100644 index 000000000..b57e71401 --- /dev/null +++ b/taiga/projects/apps.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig +from django.apps import apps +from django.db.models import signals + + +## Project Signals + +def connect_projects_signals(): + from . import signals as handlers + from .tagging import signals as tagging_handlers + # On project object is created apply template. + signals.post_save.connect(handlers.project_post_save, + sender=apps.get_model("projects", "Project"), + dispatch_uid='project_post_save') + + # Tags normalization after save a project + signals.pre_save.connect(tagging_handlers.tags_normalization, + sender=apps.get_model("projects", "Project"), + dispatch_uid="tags_normalization_projects") + + +def disconnect_projects_signals(): + signals.post_save.disconnect(sender=apps.get_model("projects", "Project"), + dispatch_uid='project_post_save') + signals.pre_save.disconnect(sender=apps.get_model("projects", "Project"), + dispatch_uid="tags_normalization_projects") + + +## Memberships Signals + +def connect_memberships_signals(): + from . import signals as handlers + # On membership object is deleted, update role-points relation. + signals.pre_delete.connect(handlers.membership_post_delete, + sender=apps.get_model("projects", "Membership"), + dispatch_uid='membership_pre_delete') + + # 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='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='membership_post_save') + + +## US Statuses Signals + +def connect_us_status_signals(): + from . import signals as handlers + signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_us_status, + sender=apps.get_model("projects", "UserStoryStatus"), + dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status") + signals.post_save.connect(handlers.create_swimlane_user_story_statuses_on_userstory_status_post_save, + sender=apps.get_model("projects", "UserStoryStatus"), + dispatch_uid="create_swimlane_user_story_statuses_on_userstory_status_post_save") + + +def disconnect_us_status_signals(): + signals.post_save.disconnect(sender=apps.get_model("projects", "UserStoryStatus"), + dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status") + signals.post_save.disconnect(sender=apps.get_model("projects", "UserStoryStatus"), + dispatch_uid="create_swimlane_user_story_statuses_on_userstory_status_post_save") + + +## Swimlane Signals + +def connect_swimlane_signals(): + from . import signals as handlers + signals.post_save.connect(handlers.create_swimlane_user_story_statuses_on_swimalne_post_save, + sender=apps.get_model("projects", "Swimlane"), + dispatch_uid="create_swimlane_user_story_statuses_on_swimalne_post_save") + signals.post_save.connect(handlers.set_default_project_swimlane_on_swimalne_post_save, + sender=apps.get_model("projects", "Swimlane"), + dispatch_uid="set_default_project_swimlane_on_swimalne_post_save") + + +def disconnect_swimlane_signals(): + signals.post_save.disconnect(sender=apps.get_model("projects", "Swimlane"), + dispatch_uid="create_swimlane_user_story_statuses_on_swimalne_post_save") + signals.post_save.disconnect(sender=apps.get_model("projects", "Swimlane"), + dispatch_uid="set_default_project_swimlane_on_swimalne_post_save") + + + +## Tasks Statuses Signals + +def connect_task_status_signals(): + from . import signals as handlers + signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_task_status, + sender=apps.get_model("projects", "TaskStatus"), + dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status") + + +def disconnect_task_status_signals(): + signals.post_save.disconnect(sender=apps.get_model("projects", "TaskStatus"), + dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status") + + +class ProjectsAppConfig(AppConfig): + name = "taiga.projects" + verbose_name = "Projects" + watched_types = [ + "projects.userstorystatus", + "projects.swimlane", + "projects.swimlaneuserstorystatus" + ] + + def ready(self): + connect_projects_signals() + connect_memberships_signals() + connect_us_status_signals() + connect_swimlane_signals() + connect_task_status_signals() diff --git a/taiga/projects/attachments/__init__.py b/taiga/projects/attachments/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/attachments/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/attachments/admin.py b/taiga/projects/attachments/admin.py new file mode 100644 index 000000000..811bf3f80 --- /dev/null +++ b/taiga/projects/attachments/admin.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin +from django.contrib.contenttypes.admin import GenericTabularInline + +from . import models + + +class AttachmentAdmin(admin.ModelAdmin): + list_display = ["id", "project", "attached_file", "owner", "content_type", "content_object"] + list_display_links = ["id", "attached_file",] + search_fields = ["id", "attached_file", "project__name", "project__slug"] + raw_id_fields = ["project"] + + def get_object(self, *args, **kwargs): + self.obj = super().get_object(*args, **kwargs) + return self.obj + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name in ["owner"] and getattr(self, 'obj', None): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.obj.project) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +class AttachmentInline(GenericTabularInline): + model = models.Attachment + fields = ("attached_file", "owner") + extra = 0 + + +admin.site.register(models.Attachment, AttachmentAdmin) diff --git a/taiga/projects/attachments/api.py b/taiga/projects/attachments/api.py new file mode 100644 index 000000000..b0583a95b --- /dev/null +++ b/taiga/projects/attachments/api.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import os.path as path +import mimetypes +mimetypes.init() + +from django.utils.translation import gettext as _ +from django.contrib.contenttypes.models import ContentType + +from taiga.base import filters +from taiga.base import exceptions as exc +from taiga.base import response +from taiga.base.api import ModelCrudViewSet +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.api.utils import get_object_or_404, get_object_or_error +from taiga.base.decorators import list_route + +from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.history.mixins import HistoryResourceMixin + +from . import permissions +from . import serializers +from . import services +from . import validators +from . import models + + +class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin, + BlockedByProjectMixin, ModelCrudViewSet): + + model = models.Attachment + serializer_class = serializers.AttachmentSerializer + validator_class = validators.AttachmentValidator + filter_fields = ["project", "object_id"] + + content_type = None + + def update(self, *args, **kwargs): + partial = kwargs.get("partial", False) + if not partial: + raise exc.NotSupported(_("Partial updates are not supported")) + return super().update(*args, **kwargs) + + def get_content_type(self): + app_name, model = self.content_type.split(".", 1) + return get_object_or_404(ContentType, app_label=app_name, model=model) + + def pre_save(self, obj): + if not obj.id: + obj.content_type = self.get_content_type() + obj.owner = self.request.user + obj.size = obj.attached_file.size + obj.name = path.basename(obj.attached_file.name) + + if obj.content_object is None: + raise exc.WrongArguments(_("Object id issue doesn't exist")) + + if obj.project_id != obj.content_object.project_id: + raise exc.WrongArguments(_("Project ID does not match between object and project")) + + super().pre_save(obj) + + def post_delete(self, obj): + # NOTE: When destroy an attachment, the content_object change + # after and not before + self.persist_history_snapshot(obj, delete=True) + super().post_delete(obj) + + def get_object_for_snapshot(self, obj): + return obj.content_object + + @list_route(methods=["POST"]) + def bulk_update_order(self, request, **kwargs): + contenttype = self.get_content_type() + # Validate data + data = request.DATA.copy() + data["content_type_id"] = contenttype.id + + validator = validators.UpdateAttachmentsOrderBulkValidator(data=data) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + # Get and validate permissions + item = contenttype.get_object_for_this_type(pk=data["object_id"]) + self.check_permissions(request, "bulk_update_order", item.project) + if item.project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + # Get after_attachment + after_attachment = None + after_attachment_id = data.get("after_attachment_id", None) + if after_attachment_id is not None: + after_attachment = get_object_or_error(item.attachments, request.user, pk=after_attachment_id) + + ret = services.update_order_in_bulk(item=item, + after_attachment=after_attachment, + bulk_attachments=data["bulk_attachments"]) + return response.Ok(ret) + + +class EpicAttachmentViewSet(BaseAttachmentViewSet): + permission_classes = (permissions.EpicAttachmentPermission,) + filter_backends = (filters.CanViewEpicAttachmentFilterBackend,) + content_type = "epics.epic" + + +class UserStoryAttachmentViewSet(BaseAttachmentViewSet): + permission_classes = (permissions.UserStoryAttachmentPermission,) + filter_backends = (filters.CanViewUserStoryAttachmentFilterBackend,) + content_type = "userstories.userstory" + + +class IssueAttachmentViewSet(BaseAttachmentViewSet): + permission_classes = (permissions.IssueAttachmentPermission,) + filter_backends = (filters.CanViewIssueAttachmentFilterBackend,) + content_type = "issues.issue" + + +class TaskAttachmentViewSet(BaseAttachmentViewSet): + permission_classes = (permissions.TaskAttachmentPermission,) + filter_backends = (filters.CanViewTaskAttachmentFilterBackend,) + content_type = "tasks.task" + + +class WikiAttachmentViewSet(BaseAttachmentViewSet): + permission_classes = (permissions.WikiAttachmentPermission,) + filter_backends = (filters.CanViewWikiAttachmentFilterBackend,) + content_type = "wiki.wikipage" diff --git a/taiga/projects/attachments/apps.py b/taiga/projects/attachments/apps.py new file mode 100644 index 000000000..aa1218deb --- /dev/null +++ b/taiga/projects/attachments/apps.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig +from django.apps import apps + + +class AttachmentsAppConfig(AppConfig): + name = "taiga.projects.attachments" + verbose_name = "Attachments" diff --git a/taiga/projects/attachments/management/commands/__init__.py b/taiga/projects/attachments/management/commands/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/attachments/management/commands/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/attachments/management/commands/generate_sha1.py b/taiga/projects/attachments/management/commands/generate_sha1.py new file mode 100644 index 000000000..7c1443fad --- /dev/null +++ b/taiga/projects/attachments/management/commands/generate_sha1.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from taiga.projects.attachments.models import Attachment + +import logging +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + @transaction.atomic + def handle(self, *args, **options): + total = rest = Attachment.objects.all().count() + + for attachment in Attachment.objects.all().order_by("id"): + attachment.save() + + rest -= 1 + logger.debug("[{} / {} remaining] - Generate sha1 for attach {}".format(rest, total, attachment.id)) diff --git a/taiga/projects/attachments/migrations/0001_initial.py b/taiga/projects/attachments/migrations/0001_initial.py new file mode 100644 index 000000000..0d336206c --- /dev/null +++ b/taiga/projects/attachments/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.projects.attachments.models +from django.conf import settings +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ('contenttypes', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0002_auto_20140903_0920'), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), + ('object_id', models.PositiveIntegerField(verbose_name='object id')), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('attached_file', models.FileField(verbose_name='attached file', upload_to=taiga.projects.attachments.models.get_attachment_file_path, blank=True, null=True, max_length=500)), + ('is_deprecated', models.BooleanField(verbose_name='is deprecated', default=False)), + ('description', models.TextField(verbose_name='description', blank=True)), + ('order', models.IntegerField(verbose_name='order', default=0)), + ('content_type', models.ForeignKey(verbose_name='content type', to='contenttypes.ContentType', on_delete=models.CASCADE)), + ('owner', models.ForeignKey(verbose_name='owner', null=True, related_name='change_attachments', to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL)), + ('project', models.ForeignKey(verbose_name='project', related_name='attachments', to='projects.Project', on_delete=models.CASCADE)), + ], + options={ + 'ordering': ['project', 'created_date'], + 'verbose_name': 'attachment', + 'verbose_name_plural': 'attachments', + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/projects/attachments/migrations/0002_add_size_and_name_fields.py b/taiga/projects/attachments/migrations/0002_add_size_and_name_fields.py new file mode 100644 index 000000000..04abffd48 --- /dev/null +++ b/taiga/projects/attachments/migrations/0002_add_size_and_name_fields.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +import os.path as path +from django.db import models, migrations + + +def parse_filenames_and_sizes(apps, schema_editor): + Attachment = apps.get_model("attachments", "Attachment") + + for item in Attachment.objects.all(): + try: + item.size = item.attached_file.size + except Exception as e: + item.size = 0 + + item.name = path.basename(item.attached_file.name) + item.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachments', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='name', + field=models.CharField(default='', blank=True, max_length=500), + preserve_default=True, + ), + migrations.AddField( + model_name='attachment', + name='size', + field=models.IntegerField(editable=False, null=True, blank=True, default=None), + preserve_default=True, + ), + migrations.RunPython(parse_filenames_and_sizes) + ] diff --git a/taiga/projects/attachments/migrations/0003_auto_20150114_0954.py b/taiga/projects/attachments/migrations/0003_auto_20150114_0954.py new file mode 100644 index 000000000..e317bd376 --- /dev/null +++ b/taiga/projects/attachments/migrations/0003_auto_20150114_0954.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachments', '0002_add_size_and_name_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='attachment', + options={'ordering': ['project', 'created_date', 'id'], 'verbose_name_plural': 'attachments', 'verbose_name': 'attachment'}, + ), + ] diff --git a/taiga/projects/attachments/migrations/0004_auto_20150508_1141.py b/taiga/projects/attachments/migrations/0004_auto_20150508_1141.py new file mode 100644 index 000000000..aa39eb17a --- /dev/null +++ b/taiga/projects/attachments/migrations/0004_auto_20150508_1141.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachments', '0003_auto_20150114_0954'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='owner', + field=models.ForeignKey(verbose_name='owner', blank=True, related_name='change_attachments', to=settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL), + preserve_default=True, + ), + ] diff --git a/taiga/projects/attachments/migrations/0005_attachment_sha1.py b/taiga/projects/attachments/migrations/0005_attachment_sha1.py new file mode 100644 index 000000000..4b4de1939 --- /dev/null +++ b/taiga/projects/attachments/migrations/0005_attachment_sha1.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachments', '0004_auto_20150508_1141'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='sha1', + field=models.CharField(default='', verbose_name='sha1', max_length=40, blank=True), + preserve_default=True, + ), + ] \ No newline at end of file diff --git a/taiga/projects/attachments/migrations/0006_auto_20160617_1233.py b/taiga/projects/attachments/migrations/0006_auto_20160617_1233.py new file mode 100644 index 000000000..d6a80a7c4 --- /dev/null +++ b/taiga/projects/attachments/migrations/0006_auto_20160617_1233.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-17 12:33 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachments', '0005_attachment_sha1'), + ] + + operations = [ + migrations.AlterIndexTogether( + name='attachment', + index_together=set([('content_type', 'object_id')]), + ), + ] diff --git a/taiga/projects/attachments/migrations/0007_attachment_from_comment.py b/taiga/projects/attachments/migrations/0007_attachment_from_comment.py new file mode 100644 index 000000000..6bd3e358c --- /dev/null +++ b/taiga/projects/attachments/migrations/0007_attachment_from_comment.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.4 on 2017-01-17 07:45 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachments', '0006_auto_20160617_1233'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='from_comment', + field=models.BooleanField(default=False, verbose_name='from_comment'), + ), + ] diff --git a/taiga/projects/attachments/migrations/0008_auto_20170201_1053.py b/taiga/projects/attachments/migrations/0008_auto_20170201_1053.py new file mode 100644 index 000000000..1d03a1b46 --- /dev/null +++ b/taiga/projects/attachments/migrations/0008_auto_20170201_1053.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.4 on 2017-02-01 10:53 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachments', '0007_attachment_from_comment'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='from_comment', + field=models.BooleanField(default=False, verbose_name='from comment'), + ), + ] diff --git a/taiga/projects/attachments/migrations/__init__.py b/taiga/projects/attachments/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/attachments/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/attachments/models.py b/taiga/projects/attachments/models.py new file mode 100644 index 000000000..c6f94116a --- /dev/null +++ b/taiga/projects/attachments/models.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import hashlib + +from django.db import models +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.utils.text import get_valid_filename + +from taiga.base.utils.files import get_file_path + + +def get_attachment_file_path(instance, filename): + return get_file_path(instance, filename, "attachments") + + +class Attachment(models.Model): + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + related_name="change_attachments", + verbose_name=_("owner"), + on_delete=models.SET_NULL, + ) + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="attachments", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + content_type = models.ForeignKey( + ContentType, + null=False, + blank=False, + verbose_name=_("content type"), + on_delete=models.CASCADE, + ) + object_id = models.PositiveIntegerField(null=False, blank=False, + verbose_name=_("object id")) + content_object = GenericForeignKey("content_type", "object_id") + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + name = models.CharField(blank=True, default="", max_length=500) + size = models.IntegerField(null=True, blank=True, editable=False, default=None) + attached_file = models.FileField(max_length=500, null=True, blank=True, + upload_to=get_attachment_file_path, + verbose_name=_("attached file")) + + sha1 = models.CharField(default="", max_length=40, verbose_name=_("sha1"), blank=True) + + is_deprecated = models.BooleanField(default=False, verbose_name=_("is deprecated")) + from_comment = models.BooleanField(default=False, verbose_name=_("from comment")) + description = models.TextField(null=False, blank=True, verbose_name=_("description")) + order = models.IntegerField(default=0, null=False, blank=False, verbose_name=_("order")) + + _importing = None + + class Meta: + verbose_name = "attachment" + verbose_name_plural = "attachments" + ordering = ["project", "created_date", "id"] + index_together = [("content_type", "object_id")] + + def __init__(self, *args, **kwargs): + super(Attachment, self).__init__(*args, **kwargs) + self._orig_attached_file = self.attached_file + + def _generate_sha1(self, blocksize=65536): + hasher = hashlib.sha1() + while True: + buff = self.attached_file.file.read(blocksize) + if not buff: + break + hasher.update(buff) + self.sha1 = hasher.hexdigest() + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + if self.attached_file: + if not self.sha1 or self.attached_file != self._orig_attached_file: + self._generate_sha1() + save = super().save(*args, **kwargs) + self._orig_attached_file = self.attached_file + if self.attached_file: + self.attached_file.file.close() + return save + + def __str__(self): + return "Attachment: {}".format(self.id) diff --git a/taiga/projects/attachments/permissions.py b/taiga/projects/attachments/permissions.py new file mode 100644 index 000000000..851d4b3d0 --- /dev/null +++ b/taiga/projects/attachments/permissions.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, + AllowAny, PermissionComponent) + + +class IsAttachmentOwnerPerm(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if obj and obj.owner and request.user.is_authenticated: + return request.user == obj.owner + return False + +class CommentAttachmentPerm(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if obj.from_comment: + return True + return False + + +class EpicAttachmentPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_epics') | IsAttachmentOwnerPerm() + create_perms = HasProjectPerm('modify_epic') | (CommentAttachmentPerm() & HasProjectPerm('comment_epic')) + update_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm() + partial_update_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm() + bulk_update_order_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm() + destroy_perms = HasProjectPerm('modify_epic') | IsAttachmentOwnerPerm() + list_perms = AllowAny() + + +class UserStoryAttachmentPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_us') | IsAttachmentOwnerPerm() + create_perms = HasProjectPerm('modify_us') | (CommentAttachmentPerm() & HasProjectPerm('comment_us')) + update_perms = HasProjectPerm('modify_us') | IsAttachmentOwnerPerm() + partial_update_perms = HasProjectPerm('modify_us') | IsAttachmentOwnerPerm() + bulk_update_order_perms = HasProjectPerm('modify_us') | IsAttachmentOwnerPerm() + destroy_perms = HasProjectPerm('modify_us') | IsAttachmentOwnerPerm() + list_perms = AllowAny() + + +class TaskAttachmentPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_tasks') | IsAttachmentOwnerPerm() + create_perms = HasProjectPerm('modify_task') | (CommentAttachmentPerm() & HasProjectPerm('comment_task')) + update_perms = HasProjectPerm('modify_task') | IsAttachmentOwnerPerm() + partial_update_perms = HasProjectPerm('modify_task') | IsAttachmentOwnerPerm() + bulk_update_order_perms = HasProjectPerm('modify_task') | IsAttachmentOwnerPerm() + destroy_perms = HasProjectPerm('modify_task') | IsAttachmentOwnerPerm() + list_perms = AllowAny() + + +class IssueAttachmentPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_issues') | IsAttachmentOwnerPerm() + create_perms = HasProjectPerm('modify_issue') | (CommentAttachmentPerm() & HasProjectPerm('comment_issue')) + update_perms = HasProjectPerm('modify_issue') | IsAttachmentOwnerPerm() + partial_update_perms = HasProjectPerm('modify_issue') | IsAttachmentOwnerPerm() + bulk_update_order_perms = HasProjectPerm('modify_issue') | IsAttachmentOwnerPerm() + destroy_perms = HasProjectPerm('modify_issue') | IsAttachmentOwnerPerm() + list_perms = AllowAny() + + +class WikiAttachmentPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_wiki_pages') | IsAttachmentOwnerPerm() + create_perms = HasProjectPerm('modify_wiki_page') | (CommentAttachmentPerm() & HasProjectPerm('comment_wiki_page')) + update_perms = HasProjectPerm('modify_wiki_page') | IsAttachmentOwnerPerm() + partial_update_perms = HasProjectPerm('modify_wiki_page') | IsAttachmentOwnerPerm() + bulk_update_order_perms = HasProjectPerm('modify_wiki_page') | IsAttachmentOwnerPerm() + destroy_perms = HasProjectPerm('modify_wiki_page') | IsAttachmentOwnerPerm() + list_perms = AllowAny() + + +class RawAttachmentPerm(PermissionComponent): + def check_permissions(self, request, view, obj=None): + is_owner = IsAttachmentOwnerPerm().check_permissions(request, view, obj) + if obj.content_type.app_label == "epics" and obj.content_type.model == "epic": + return EpicAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner + elif obj.content_type.app_label == "userstories" and obj.content_type.model == "userstory": + return UserStoryAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner + elif obj.content_type.app_label == "tasks" and obj.content_type.model == "task": + return TaskAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner + elif obj.content_type.app_label == "issues" and obj.content_type.model == "issue": + return IssueAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner + elif obj.content_type.app_label == "wiki" and obj.content_type.model == "wikipage": + return WikiAttachmentPermission(request, view).check_permissions('retrieve', obj) or is_owner + return False + + +class RawAttachmentPermission(TaigaResourcePermission): + retrieve_perms = RawAttachmentPerm() diff --git a/taiga/projects/attachments/serializers.py b/taiga/projects/attachments/serializers.py new file mode 100644 index 000000000..93d610edd --- /dev/null +++ b/taiga/projects/attachments/serializers.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings + +from taiga.base.api import serializers +from taiga.base.fields import MethodField, Field, FileField +from taiga.base.utils.thumbnails import get_thumbnail_url + +from . import services + + +class AttachmentSerializer(serializers.LightSerializer): + id = Field() + project = Field(attr="project_id") + owner = Field(attr="owner_id") + name = Field() + attached_file = FileField() + size = Field() + url = Field() + description = Field() + is_deprecated = Field() + from_comment = Field() + created_date = Field() + modified_date = Field() + object_id = Field() + order = Field() + sha1 = Field() + url = MethodField("get_url") + thumbnail_card_url = MethodField("get_thumbnail_card_url") + preview_url = MethodField("get_preview_url") + + def get_url(self, obj): + frag = services.generate_refresh_fragment(obj) + return "{}#{}".format(obj.attached_file.url, frag) + + def get_thumbnail_card_url(self, obj): + return services.get_card_image_thumbnail_url(obj) + + def get_preview_url(self, obj): + if obj.attached_file.name.lower().endswith(".psd"): + return services.get_attachment_image_preview_url(obj) + return self.get_url(obj) + + +class BasicAttachmentsInfoSerializerMixin(serializers.LightSerializer): + """ + Assumptions: + - The queryset has an attribute called "include_attachments" indicating if the attachments array should contain information + about the related elements, otherwise it will be empty + - The method attach_basic_attachments has been used to include the necessary + json data about the attachments in the "attachments_attr" column + """ + attachments = MethodField() + + def get_attachments(self, obj): + include_attachments = getattr(obj, "include_attachments", False) + + if include_attachments: + assert hasattr(obj, "attachments_attr"), "instance must have a attachments_attr attribute" + + if not include_attachments or obj.attachments_attr is None: + return [] + + for at in obj.attachments_attr: + at["thumbnail_card_url"] = get_thumbnail_url(at["attached_file"], settings.THN_ATTACHMENT_CARD) + + return obj.attachments_attr diff --git a/taiga/projects/attachments/services.py b/taiga/projects/attachments/services.py new file mode 100644 index 000000000..399ea2d3c --- /dev/null +++ b/taiga/projects/attachments/services.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from typing import List, Optional, Union + +from urllib.parse import parse_qs, urldefrag + +from django.apps import apps +from django.db import connection +from django.conf import settings + +from psycopg2.extras import execute_values + +from taiga.base.utils.thumbnails import get_thumbnail_url, get_thumbnail + +from . import models + +# Refresh feature + +REFRESH_PARAM = "_taiga-refresh" + + +def get_attachment_by_id(project_id, attachment_id): + model_cls = apps.get_model("attachments", "Attachment") + try: + obj = model_cls.objects.select_related("content_type").get(id=attachment_id) + except model_cls.DoesNotExist: + return None + + if not obj.content_object or obj.content_object.project_id != project_id: + return None + + return obj + + +def generate_refresh_fragment(attachment, type_=""): + if not attachment: + return '' + type_ = attachment.content_type.model if not type_ else type_ + return "{}={}:{}".format(REFRESH_PARAM, type_, attachment.id) + + +def extract_refresh_id(url): + if not url: + return False, False + _, frag = urldefrag(url) + if not frag: + return False, False + qs = parse_qs(frag) + if not qs: + return False, False + ref = qs.get(REFRESH_PARAM, False) + if not ref: + return False, False + type_, _, id_ = ref[0].partition(":") + try: + return type_, int(id_) + except ValueError: + return False, False + + +def url_is_an_attachment(url: str, base=None) -> "Union[str, None]": + if not url: + return None + return url if url.startswith(base or settings.MEDIA_URL) else None + + +# Thumbnail services + +def get_timeline_image_thumbnail_name(attachment): + if attachment.attached_file: + thumbnail = get_thumbnail(attachment.attached_file, settings.THN_ATTACHMENT_TIMELINE) + return thumbnail.name if thumbnail else None + return None + + +def get_card_image_thumbnail_url(attachment): + if attachment.attached_file: + return get_thumbnail_url(attachment.attached_file, settings.THN_ATTACHMENT_CARD) + return None + + +def get_attachment_image_preview_url(attachment): + if attachment.attached_file: + return get_thumbnail_url(attachment.attached_file, settings.THN_ATTACHMENT_PREVIEW) + return None + + +# Sorting attachments + +def update_order_in_bulk(item: Union["Epic", "UserStory", "Task", "Issue", "WikiPage"], + bulk_attachments: List[int], + after_attachment: Optional[models.Attachment] = None): + """ + Updates the order of the attachments specified adding the extra updates + needed to keep consistency. + + - `bulk_attachments` should be a list of attachment IDs + """ + + # get item attachments + attachments = item.attachments.all() + + # exclude moved attachments + attachments = attachments.exclude(id__in=bulk_attachments) + + # if after_attachment, exclude it and get only elements after it: + if after_attachment: + attachments = (attachments.exclude(id=after_attachment.id) + .filter(order__gte=after_attachment.order)) + + # sort and get only ids + attachment_ids = (attachments.order_by("order", "id") + .values_list('id', flat=True)) + + # append moved user stories + attachment_ids = bulk_attachments + list(attachment_ids) + + # calculate the start order + if after_attachment: + # order start after the after_attachment order + start_order = after_attachment.order + 1 + else: + # move at the beggining of the column if there is no after and before + start_order = 1 + + # prepare rest of data + total_attachments = len(attachment_ids) + attachment_orders = range(start_order, start_order + total_attachments) + + data = tuple(zip(attachment_ids, + attachment_orders)) + + # execute query for update order + sql = """ + UPDATE attachments_attachment + SET "order" = tmp.new_order::BIGINT + FROM (VALUES %s) AS tmp (id, new_order) + WHERE tmp.id = attachments_attachment.id + """ + + with connection.cursor() as cursor: + execute_values(cursor, sql, data) + + # Generate response with modified info + res = ({ + "id": id, + "order": order + } for (id, order) in data) + return res diff --git a/taiga/projects/attachments/utils.py b/taiga/projects/attachments/utils.py new file mode 100644 index 000000000..5d05525d1 --- /dev/null +++ b/taiga/projects/attachments/utils.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps + + +def attach_total_attachments(queryset, as_field="total_attachments"): + """Attach attachment count to each object of the queryset. + + :param queryset: A Django queryset object. + :param as_field: Attach the attachments count as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + sql = """SELECT count(*) + FROM attachments_attachment + WHERE attachments_attachment.content_type_id = {type_id} + AND attachments_attachment.object_id = {tbl}.id""" + + sql = sql.format(type_id=type.id, tbl=model._meta.db_table) + qs = queryset.extra(select={as_field: sql}) + return qs + + +def attach_basic_attachments(queryset, as_field="attachments_attr"): + """Attach basic attachments info as json column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach the role points as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + + sql = """SELECT json_agg(row_to_json(t)) + FROM( + SELECT + attachments_attachment.id, + attachments_attachment.attached_file + FROM attachments_attachment + WHERE attachments_attachment.object_id = {tbl}.id + AND attachments_attachment.content_type_id = {type_id} + AND attachments_attachment.is_deprecated = False + ORDER BY attachments_attachment.order, attachments_attachment.created_date, attachments_attachment.id) t""" + + sql = sql.format(tbl=model._meta.db_table, type_id=type.id) + queryset = queryset.extra(select={as_field: sql}) + return queryset diff --git a/taiga/projects/attachments/validators.py b/taiga/projects/attachments/validators.py new file mode 100644 index 000000000..2b5984530 --- /dev/null +++ b/taiga/projects/attachments/validators.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.exceptions import ValidationError +from taiga.base.fields import ListField + +from . import models + + +class AttachmentValidator(validators.ModelValidator): + attached_file = serializers.FileField(required=True) + + class Meta: + model = models.Attachment + fields = ("id", "project", "owner", "name", "attached_file", "size", + "description", "is_deprecated", "created_date", + "modified_date", "object_id", "order", "sha1", "from_comment") + read_only_fields = ("owner", "created_date", "modified_date", "sha1") + + +class UpdateAttachmentsOrderBulkValidator(validators.Validator): + content_type_id = serializers.IntegerField() + object_id = serializers.IntegerField() + after_attachment_id = serializers.IntegerField(required=False) + bulk_attachments = ListField(child=serializers.IntegerField(min_value=1)) + + def validate_after_attachment_id(self, attrs, source): + if (attrs.get(source, None) is not None + and attrs.get("content_type_id", None) is not None + and attrs.get("object_id", None) is not None): + filters = { + "content_type__id": attrs["content_type_id"], + "object_id": attrs["object_id"], + "id": attrs[source] + } + + if not models.Attachment.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid attachment id to move after. The attachment must belong " + "to the same item (epic, userstory, task, issue or wiki page).")) + + return attrs + + def validate_bulk_attachments(self, attrs, source): + if (attrs.get("content_type_id", None) is not None + and attrs.get("object_id", None) is not None): + filters = { + "content_type__id": attrs["content_type_id"], + "object_id": attrs["object_id"], + "id__in": attrs[source] + } + + if models.Attachment.objects.filter(**filters).count() != len(filters["id__in"]): + raise ValidationError(_("Invalid attachment ids. All attachments must belong to the same " + "item (epic, userstory, task, issue or wiki page).")) + + return attrs diff --git a/taiga/projects/choices.py b/taiga/projects/choices.py new file mode 100644 index 000000000..3acef1ca8 --- /dev/null +++ b/taiga/projects/choices.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext_lazy as _ + + +VIDEOCONFERENCES_CHOICES = ( + ("whereby-com", _("Whereby.com")), + ("jitsi", _("Jitsi")), + ("custom", _("Custom")), + ("talky", _("Talky")), +) + +BLOCKED_BY_NONPAYMENT = "blocked-by-nonpayment" +BLOCKED_BY_STAFF = "blocked-by-staff" +BLOCKED_BY_OWNER_LEAVING = "blocked-by-owner-leaving" +BLOCKED_BY_DELETING = "blocked-by-deleting" + +BLOCKING_CODES = [ + (BLOCKED_BY_NONPAYMENT, _("This project is blocked due to payment failure")), + (BLOCKED_BY_STAFF, _("This project is blocked by admin staff")), + (BLOCKED_BY_OWNER_LEAVING, _("This project is blocked because the owner left")), + (BLOCKED_BY_DELETING, _("This project is blocked while it's deleted")) +] diff --git a/taiga/projects/contact/__init__.py b/taiga/projects/contact/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/contact/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/contact/admin.py b/taiga/projects/contact/admin.py new file mode 100644 index 000000000..bfcc00ba9 --- /dev/null +++ b/taiga/projects/contact/admin.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin + +from . import models + + +class ContactEntryAdmin(admin.ModelAdmin): + list_display = ["created_date", "project", "user"] + list_display_links = list_display + list_filter = ["created_date", ] + date_hierarchy = "created_date" + ordering = ("-created_date", "id") + search_fields = ("project__name", "project__slug", "user__username", "user__email", "user__full_name") + +admin.site.register(models.ContactEntry, ContactEntryAdmin) diff --git a/taiga/projects/contact/api.py b/taiga/projects/contact/api.py new file mode 100644 index 000000000..b9656d3f8 --- /dev/null +++ b/taiga/projects/contact/api.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import status +from taiga.base.api.mixins import CreateModelMixin, BlockedByProjectMixin +from taiga.base.api.viewsets import GenericViewSet + +from . import models +from . import permissions +from . import services +from . import validators + +from django.conf import settings + + +class ContactViewSet(BlockedByProjectMixin, CreateModelMixin, GenericViewSet): + permission_classes = (permissions.ContactPermission,) + validator_class = validators.ContactEntryValidator + model = models.ContactEntry + + def create(self, *args, **kwargs): + response = super().create(*args, **kwargs) + + if response.status_code == status.HTTP_201_CREATED: + if settings.CELERY_ENABLED: + services.send_contact_email.delay(self.object.id) + else: + services.send_contact_email(self.object.id) + + return response + + def pre_save(self, obj): + obj.user = self.request.user + super().pre_save(obj) diff --git a/taiga/projects/contact/migrations/0001_initial.py b/taiga/projects/contact/migrations/0001_initial.py new file mode 100644 index 000000000..4568d1196 --- /dev/null +++ b/taiga/projects/contact/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-11-10 15:18 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0056_auto_20161110_1518'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ContactEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.TextField(verbose_name='comment')), + ('created_date', models.DateTimeField(auto_now_add=True, verbose_name='created date')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_entries', to='projects.Project', verbose_name='project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_entries', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'contact entry', + 'ordering': ['-created_date', 'id'], + 'verbose_name_plural': 'contact entries', + }, + ), + ] diff --git a/taiga/projects/contact/migrations/__init__.py b/taiga/projects/contact/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/contact/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/contact/models.py b/taiga/projects/contact/models.py new file mode 100644 index 000000000..4ae27b0e3 --- /dev/null +++ b/taiga/projects/contact/models.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ContactEntry(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="contact_entries", + verbose_name=_("user"), + on_delete=models.CASCADE + ) + + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="contact_entries", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + comment = models.TextField(null=False, blank=False, verbose_name=_("comment")) + + created_date = models.DateTimeField(null=False, blank=False, auto_now_add=True, + verbose_name=_("created date")) + + class Meta: + verbose_name = "contact entry" + verbose_name_plural = "contact entries" + ordering = ["-created_date", "id"] diff --git a/taiga/projects/contact/permissions.py b/taiga/projects/contact/permissions.py new file mode 100644 index 000000000..3c1682f03 --- /dev/null +++ b/taiga/projects/contact/permissions.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import PermissionComponent +from taiga.base.api.permissions import TaigaResourcePermission + + +class IsContactActivated(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if not request.user.is_authenticated or not obj.project.is_contact_activated: + return False + + if obj.project.is_private: + return obj.project.cached_memberships_for_user(request.user) + + return True + + +class ContactPermission(TaigaResourcePermission): + create_perms = IsContactActivated() diff --git a/taiga/projects/contact/services.py b/taiga/projects/contact/services.py new file mode 100644 index 000000000..b80a098a3 --- /dev/null +++ b/taiga/projects/contact/services.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.mails import mail_builder +from taiga.celery import app +from taiga.front.templatetags.functions import resolve as resolve_front_url +from taiga.users.services import get_user_photo_url + +from . import models + + +@app.task +def send_contact_email(contact_entry_id): + contact_entry = models.ContactEntry.objects.filter(id=contact_entry_id).first() + if contact_entry is None: + return + + ctx = { + "comment": contact_entry.comment, + "full_name": contact_entry.user.get_full_name(), + "project_name": contact_entry.project.name, + "photo_url": get_user_photo_url(contact_entry.user), + "user_profile_url": resolve_front_url("user", contact_entry.user.username), + "project_settings_url": resolve_front_url("project-admin", contact_entry.project.slug), + } + admins = contact_entry.project.get_users(with_admin_privileges=True).exclude(id=contact_entry.user_id) + for admin in admins: + email = mail_builder.contact_notification(admin.email, ctx) + email.extra_headers["Reply-To"] = contact_entry.user.email + email.send() diff --git a/taiga/projects/contact/templates/emails/contact_notification-body-html.jinja b/taiga/projects/contact/templates/emails/contact_notification-body-html.jinja new file mode 100644 index 000000000..46329fb91 --- /dev/null +++ b/taiga/projects/contact/templates/emails/contact_notification-body-html.jinja @@ -0,0 +1,36 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} +

+ {% if photo_url %} + + {{ full_name }} + + {% endif %} + {% trans full_name=full_name, user_profile_url=user_profile_url, project_name=project_name %} + {{ full_name }} has written to {{ project_name }} + {% endtrans %} +

+ +

{{ comment }}

+ +
+ +

+ {% trans project_name=project_name, project_settings_url=project_settings_url %} + You are receiving this message because you are listed as administrator of the project titled {{ project_name }}. If you don't want members of the Taiga community contacting your project, please update your project settings to prevent such contacts. Regular communications amongst members of the project will not be affected. + {% endtrans %} +

+ + {% trans signature=sr("signature") %} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/contact/templates/emails/contact_notification-body-text.jinja b/taiga/projects/contact/templates/emails/contact_notification-body-text.jinja new file mode 100644 index 000000000..801b9a527 --- /dev/null +++ b/taiga/projects/contact/templates/emails/contact_notification-body-text.jinja @@ -0,0 +1,22 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans full_name=full_name, comment=comment, project_name=project_name %} +{{ full_name }} has written to {{ project_name }} +{% endtrans %} +--------- +{{ comment }} +--------- +{% trans project_name=project_name %} +You are receiving this message because you are listed as administrator of the project titled {{ project_name }}. If you don't want members of the Taiga community contacting your project, please update your project settings in {{ project_settings_url }} to prevent such contacts. Regular communications amongst members of the project will not be affected. +{% endtrans %} + +{% trans signature=sr("signature") %} +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/contact/templates/emails/contact_notification-subject.jinja b/taiga/projects/contact/templates/emails/contact_notification-subject.jinja new file mode 100644 index 000000000..11ca3cbba --- /dev/null +++ b/taiga/projects/contact/templates/emails/contact_notification-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans full_name=full_name|safe, project_name=project_name|safe %} +[Taiga] {{ full_name }} has sent a message to the project {{ project_name }} +{% endtrans %} diff --git a/taiga/projects/contact/validators.py b/taiga/projects/contact/validators.py new file mode 100644 index 000000000..3692e718a --- /dev/null +++ b/taiga/projects/contact/validators.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import validators + +from . import models + + +class ContactEntryValidator(validators.ModelValidator): + + class Meta: + model = models.ContactEntry + read_only_fields = ("user", "created_date", ) diff --git a/taiga/projects/custom_attributes/__init__.py b/taiga/projects/custom_attributes/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/custom_attributes/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/custom_attributes/admin.py b/taiga/projects/custom_attributes/admin.py new file mode 100644 index 000000000..78b1466f9 --- /dev/null +++ b/taiga/projects/custom_attributes/admin.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin + +from . import models + + +class BaseCustomAttributeAdmin: + list_display = ["id", "name", "type", "project", "order"] + list_display_links = ["id", "name"] + fieldsets = ( + (None, { + "fields": ("name", "type", "description", ("project", "order")) + }), + ("Advanced options", { + "classes": ("collapse",), + "fields": (("created_date", "modified_date"),) + }) + ) + readonly_fields = ("created_date", "modified_date") + search_fields = ["id", "name", "project__name", "project__slug"] + raw_id_fields = ["project"] + + +@admin.register(models.EpicCustomAttribute) +class EpicCustomAttributeAdmin(BaseCustomAttributeAdmin, admin.ModelAdmin): + pass + + +@admin.register(models.UserStoryCustomAttribute) +class UserStoryCustomAttributeAdmin(BaseCustomAttributeAdmin, admin.ModelAdmin): + pass + + +@admin.register(models.TaskCustomAttribute) +class TaskCustomAttributeAdmin(BaseCustomAttributeAdmin, admin.ModelAdmin): + pass + + +@admin.register(models.IssueCustomAttribute) +class IssueCustomAttributeAdmin(BaseCustomAttributeAdmin, admin.ModelAdmin): + pass diff --git a/taiga/projects/custom_attributes/api.py b/taiga/projects/custom_attributes/api.py new file mode 100644 index 000000000..d9b0b964d --- /dev/null +++ b/taiga/projects/custom_attributes/api.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelUpdateRetrieveViewSet +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base import filters + +from taiga.projects.mixins.ordering import BulkUpdateOrderMixin +from taiga.projects.history.mixins import HistoryResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.occ.mixins import OCCResourceMixin + +from . import models +from . import serializers +from . import validators +from . import permissions +from . import services + + +###################################################### +# Custom Attribute ViewSets +####################################################### + +class EpicCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet): + model = models.EpicCustomAttribute + serializer_class = serializers.EpicCustomAttributeSerializer + validator_class = validators.EpicCustomAttributeValidator + permission_classes = (permissions.EpicCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_epic_custom_attributes" + bulk_update_perm = "change_epic_custom_attributes" + bulk_update_order_action = services.bulk_update_epic_custom_attribute_order + + +class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet): + model = models.UserStoryCustomAttribute + serializer_class = serializers.UserStoryCustomAttributeSerializer + validator_class = validators.UserStoryCustomAttributeValidator + permission_classes = (permissions.UserStoryCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_userstory_custom_attributes" + bulk_update_perm = "change_userstory_custom_attributes" + bulk_update_order_action = services.bulk_update_userstory_custom_attribute_order + + +class TaskCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet): + model = models.TaskCustomAttribute + serializer_class = serializers.TaskCustomAttributeSerializer + validator_class = validators.TaskCustomAttributeValidator + permission_classes = (permissions.TaskCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_task_custom_attributes" + bulk_update_perm = "change_task_custom_attributes" + bulk_update_order_action = services.bulk_update_task_custom_attribute_order + + +class IssueCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet): + model = models.IssueCustomAttribute + serializer_class = serializers.IssueCustomAttributeSerializer + validator_class = validators.IssueCustomAttributeValidator + permission_classes = (permissions.IssueCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_issue_custom_attributes" + bulk_update_perm = "change_issue_custom_attributes" + bulk_update_order_action = services.bulk_update_issue_custom_attribute_order + + +###################################################### +# Custom Attributes Values ViewSets +####################################################### + +class BaseCustomAttributesValuesViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, + BlockedByProjectMixin, ModelUpdateRetrieveViewSet): + def get_object_for_snapshot(self, obj): + return getattr(obj, self.content_object) + + +class EpicCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.EpicCustomAttributesValues + serializer_class = serializers.EpicCustomAttributesValuesSerializer + validator_class = validators.EpicCustomAttributesValuesValidator + permission_classes = (permissions.EpicCustomAttributesValuesPermission,) + lookup_field = "epic_id" + content_object = "epic" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("epic", "epic__project") + return qs + + +class UserStoryCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.UserStoryCustomAttributesValues + serializer_class = serializers.UserStoryCustomAttributesValuesSerializer + validator_class = validators.UserStoryCustomAttributesValuesValidator + permission_classes = (permissions.UserStoryCustomAttributesValuesPermission,) + lookup_field = "user_story_id" + content_object = "user_story" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("user_story", "user_story__project") + return qs + + +class TaskCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.TaskCustomAttributesValues + serializer_class = serializers.TaskCustomAttributesValuesSerializer + validator_class = validators.TaskCustomAttributesValuesValidator + permission_classes = (permissions.TaskCustomAttributesValuesPermission,) + lookup_field = "task_id" + content_object = "task" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("task", "task__project") + return qs + + +class IssueCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.IssueCustomAttributesValues + serializer_class = serializers.IssueCustomAttributesValuesSerializer + validator_class = validators.IssueCustomAttributesValuesValidator + permission_classes = (permissions.IssueCustomAttributesValuesPermission,) + lookup_field = "issue_id" + content_object = "issue" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("issue", "issue__project") + return qs diff --git a/taiga/projects/custom_attributes/choices.py b/taiga/projects/custom_attributes/choices.py new file mode 100644 index 000000000..257dbb175 --- /dev/null +++ b/taiga/projects/custom_attributes/choices.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext_lazy as _ + + +TEXT_TYPE = "text" +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")), + (DROPDOWN_TYPE, _("Dropdown")), + (CHECKBOX_TYPE, _("Checkbox")), + (NUMBER_TYPE, _("Number")), +) diff --git a/taiga/projects/custom_attributes/migrations/0001_initial.py b/taiga/projects/custom_attributes/migrations/0001_initial.py new file mode 100644 index 000000000..2638f5baa --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0001_initial.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0015_auto_20141230_1212'), + ] + + operations = [ + migrations.CreateModel( + name='IssueCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('name', models.CharField(verbose_name='name', max_length=64)), + ('description', models.TextField(blank=True, verbose_name='description')), + ('order', models.IntegerField(verbose_name='order', default=10000)), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='issuecustomattributes', on_delete=models.CASCADE)), + ], + options={ + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'issue custom attribute', + 'verbose_name_plural': 'issue custom attributes', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='TaskCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('name', models.CharField(verbose_name='name', max_length=64)), + ('description', models.TextField(blank=True, verbose_name='description')), + ('order', models.IntegerField(verbose_name='order', default=10000)), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='taskcustomattributes', on_delete=models.CASCADE)), + ], + options={ + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'task custom attribute', + 'verbose_name_plural': 'task custom attributes', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserStoryCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('name', models.CharField(verbose_name='name', max_length=64)), + ('description', models.TextField(blank=True, verbose_name='description')), + ('order', models.IntegerField(verbose_name='order', default=10000)), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='userstorycustomattributes', on_delete=models.CASCADE)), + ], + options={ + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'user story custom attribute', + 'verbose_name_plural': 'user story custom attributes', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='userstorycustomattribute', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='taskcustomattribute', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='issuecustomattribute', + unique_together=set([('project', 'name')]), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues.py b/taiga/projects/custom_attributes/migrations/0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues.py new file mode 100644 index 000000000..989d6f653 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0005_auto_20150114_0954'), + ('issues', '0004_auto_20150114_0954'), + ('userstories', '0009_remove_userstory_is_archived'), + ('custom_attributes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='IssueCustomAttributesValues', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', taiga.base.db.models.fields.JSONField(default=dict, verbose_name='attributes_values')), + ('issue', models.OneToOneField(verbose_name='issue', to='issues.Issue', related_name='custom_attributes_values', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'issue custom attributes values', + 'ordering': ['id'], + 'verbose_name': 'issue ustom attributes values', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='TaskCustomAttributesValues', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', taiga.base.db.models.fields.JSONField(default=dict, verbose_name='attributes_values')), + ('task', models.OneToOneField(verbose_name='task', to='tasks.Task', related_name='custom_attributes_values', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'task custom attributes values', + 'ordering': ['id'], + 'verbose_name': 'task ustom attributes values', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserStoryCustomAttributesValues', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', taiga.base.db.models.fields.JSONField(default=dict, verbose_name='attributes_values')), + ('user_story', models.OneToOneField(verbose_name='user story', to='userstories.UserStory', related_name='custom_attributes_values', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'user story custom attributes values', + 'ordering': ['id'], + 'verbose_name': 'user story ustom attributes values', + 'abstract': False, + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0003_triggers_on_delete_customattribute.py b/taiga/projects/custom_attributes/migrations/0003_triggers_on_delete_customattribute.py new file mode 100644 index 000000000..ccdf4803d --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0003_triggers_on_delete_customattribute.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues'), + ] + + operations = [ + # Function: Remove a key in a json field + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + RETURNS json + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}') + FROM json_each("json") + WHERE "key" <> ALL ("keys_to_delete")), + '{}')::json $function$; + """, + reverse_sql="""DROP FUNCTION IF EXISTS "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + CASCADE;""" + ), + + # Function: Romeve a key in the json field of *_custom_attributes_values.values + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION "clean_key_in_custom_attributes_values"() + RETURNS trigger + AS $clean_key_in_custom_attributes_values$ + DECLARE + key text; + tablename text; + BEGIN + key := OLD.id::text; + tablename := TG_ARGV[0]::text; + + EXECUTE 'UPDATE ' || quote_ident(tablename) || ' + SET attributes_values = json_object_delete_keys(attributes_values, ' || + quote_literal(key) || ')'; + + RETURN NULL; + END; $clean_key_in_custom_attributes_values$ + LANGUAGE plpgsql; + + """, + reverse_sql="""DROP FUNCTION IF EXISTS "clean_key_in_custom_attributes_values"() + CASCADE;""" + ), + + # Trigger: Clean userstorycustomattributes values before remove a userstorycustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_userstorycustomvalues_after_remove_userstorycustomattribute" + AFTER DELETE ON custom_attributes_userstorycustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_userstorycustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_userstorycustomvalues_after_remove_userstorycustomattribute" + ON custom_attributes_userstorycustomattribute + CASCADE;""" + ), + + # Trigger: Clean taskcustomattributes values before remove a taskcustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_taskcustomvalues_after_remove_taskcustomattribute" + AFTER DELETE ON custom_attributes_taskcustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_taskcustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_taskcustomvalues_after_remove_taskcustomattribute" + ON custom_attributes_taskcustomattribute + CASCADE;""" + ), + + # Trigger: Clean issuecustomattributes values before remove a issuecustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_issuecustomvalues_after_remove_issuecustomattribute" + AFTER DELETE ON custom_attributes_issuecustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_issuecustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_issuecustomvalues_after_remove_issuecustomattribute" + ON custom_attributes_issuecustomattribute + CASCADE;""" + ) + ] diff --git a/taiga/projects/custom_attributes/migrations/0004_create_empty_customattributesvalues_for_existen_object.py b/taiga/projects/custom_attributes/migrations/0004_create_empty_customattributesvalues_for_existen_object.py new file mode 100644 index 000000000..6ccd01b92 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0004_create_empty_customattributesvalues_for_existen_object.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +def create_empty_user_story_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "UserStoryCustomAttributesValues") + obj_model = apps.get_model("userstories", "UserStory") + db_alias = schema_editor.connection.alias + + data = [] + for user_story in obj_model.objects.using(db_alias).all().select_related("custom_attributes_values"): + if not hasattr(user_story, "custom_attributes_values"): + data.append(cav_model(user_story=user_story,attributes_values={})) + + cav_model.objects.using(db_alias).bulk_create(data) + + +def delete_empty_user_story_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "UserStoryCustomAttributesValues") + db_alias = schema_editor.connection.alias + + cav_model.objects.using(db_alias).extra(where=["attributes_values::text <> '{}'::text"]).delete() + + +def create_empty_task_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "TaskCustomAttributesValues") + obj_model = apps.get_model("tasks", "Task") + db_alias = schema_editor.connection.alias + + data = [] + for task in obj_model.objects.using(db_alias).all().select_related("custom_attributes_values"): + if not hasattr(task, "custom_attributes_values"): + data.append(cav_model(task=task,attributes_values={})) + + cav_model.objects.using(db_alias).bulk_create(data) + + +def delete_empty_task_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "TaskCustomAttributesValues") + db_alias = schema_editor.connection.alias + + cav_model.objects.using(db_alias).extra(where=["attributes_values::text <> '{}'::text"]).delete() + + +def create_empty_issues_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "IssueCustomAttributesValues") + obj_model = apps.get_model("issues", "Issue") + db_alias = schema_editor.connection.alias + + data = [] + for issue in obj_model.objects.using(db_alias).all().select_related("custom_attributes_values"): + if not hasattr(issue, "custom_attributes_values"): + data.append(cav_model(issue=issue,attributes_values={})) + + cav_model.objects.using(db_alias).bulk_create(data) + + +def delete_empty_issue_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "IssueCustomAttributesValues") + db_alias = schema_editor.connection.alias + + cav_model.objects.using(db_alias).extra(where=["attributes_values::text <> '{}'::text"]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0003_triggers_on_delete_customattribute'), + ] + + operations = [ + migrations.RunPython(create_empty_user_story_custom_attrributes_values, + reverse_code=delete_empty_user_story_custom_attrributes_values, + atomic=True), + migrations.RunPython(create_empty_task_custom_attrributes_values, + reverse_code=delete_empty_task_custom_attrributes_values, + atomic=True), + migrations.RunPython(create_empty_issues_custom_attrributes_values, + reverse_code=delete_empty_issue_custom_attrributes_values, + atomic=True), + ] diff --git a/taiga/projects/custom_attributes/migrations/0005_auto_20150505_1639.py b/taiga/projects/custom_attributes/migrations/0005_auto_20150505_1639.py new file mode 100644 index 000000000..38560d3d4 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0005_auto_20150505_1639.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0004_create_empty_customattributesvalues_for_existen_object'), + ] + + operations = [ + migrations.AlterField( + model_name='issuecustomattributesvalues', + name='attributes_values', + field=taiga.base.db.models.fields.JSONField(verbose_name='values', default=dict), + preserve_default=True, + ), + migrations.AlterField( + model_name='taskcustomattributesvalues', + name='attributes_values', + field=taiga.base.db.models.fields.JSONField(verbose_name='values', default=dict), + preserve_default=True, + ), + migrations.AlterField( + model_name='userstorycustomattributesvalues', + name='attributes_values', + field=taiga.base.db.models.fields.JSONField(verbose_name='values', default=dict), + preserve_default=True, + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0006_auto_20151014_1645.py b/taiga/projects/custom_attributes/migrations/0006_auto_20151014_1645.py new file mode 100644 index 000000000..907007777 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0006_auto_20151014_1645.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0005_auto_20150505_1639'), + ] + + operations = [ + migrations.AddField( + model_name='issuecustomattribute', + name='type', + field=models.CharField(default='text', choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date')], verbose_name='type', max_length=16), + ), + migrations.AddField( + model_name='taskcustomattribute', + name='type', + field=models.CharField(default='text', choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date')], verbose_name='type', max_length=16), + ), + migrations.AddField( + model_name='userstorycustomattribute', + name='type', + field=models.CharField(default='text', choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date')], verbose_name='type', max_length=16), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0007_auto_20160208_1751.py b/taiga/projects/custom_attributes/migrations/0007_auto_20160208_1751.py new file mode 100644 index 000000000..e058f1579 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0007_auto_20160208_1751.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0006_auto_20151014_1645'), + ] + + operations = [ + migrations.AlterField( + model_name='issuecustomattribute', + name='type', + field=models.CharField(default='text', max_length=16, verbose_name='type', choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date'), ('url', 'Url')]), + ), + migrations.AlterField( + model_name='taskcustomattribute', + name='type', + field=models.CharField(default='text', max_length=16, verbose_name='type', choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date'), ('url', 'Url')]), + ), + migrations.AlterField( + model_name='userstorycustomattribute', + name='type', + field=models.CharField(default='text', max_length=16, verbose_name='type', choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date'), ('url', 'Url')]), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0008_auto_20160728_0540.py b/taiga/projects/custom_attributes/migrations/0008_auto_20160728_0540.py new file mode 100644 index 000000000..d415b9754 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0008_auto_20160728_0540.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-07-28 05:40 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0007_auto_20160208_1751'), + ] + + operations = [ + # Function: Remove a key in a json field + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + RETURNS json + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}') + FROM json_each("json") + WHERE "key" <> ALL ("keys_to_delete")), + '{}')::json $function$; + """, + reverse_sql=""" + DROP FUNCTION IF EXISTS "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + CASCADE;""" + ), + + # Function: Romeve a key in the json field of *_custom_attributes_values.values + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION "clean_key_in_custom_attributes_values"() + RETURNS trigger + AS $clean_key_in_custom_attributes_values$ + DECLARE + key text; + project_id int; + object_id int; + attribute text; + tablename text; + custom_attributes_tablename text; + BEGIN + key := OLD.id::text; + project_id := OLD.project_id; + attribute := TG_ARGV[0]::text; + tablename := TG_ARGV[1]::text; + custom_attributes_tablename := TG_ARGV[2]::text; + + EXECUTE 'UPDATE ' || quote_ident(custom_attributes_tablename) || ' + SET attributes_values = json_object_delete_keys(attributes_values, ' || quote_literal(key) || ') + FROM ' || quote_ident(tablename) || ' + WHERE ' || quote_ident(tablename) || '.project_id = ' || project_id || ' + AND ' || quote_ident(custom_attributes_tablename) || '.' || quote_ident(attribute) || ' = ' || quote_ident(tablename) || '.id'; + RETURN NULL; + END; $clean_key_in_custom_attributes_values$ + LANGUAGE plpgsql; + """ + ), + + # Trigger: Clean userstorycustomattributes values before remove a userstorycustomattribute + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS "update_userstorycustomvalues_after_remove_userstorycustomattribute" + ON custom_attributes_userstorycustomattribute + CASCADE; + + CREATE TRIGGER "update_userstorycustomvalues_after_remove_userstorycustomattribute" + AFTER DELETE ON custom_attributes_userstorycustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('user_story_id', 'userstories_userstory', + 'custom_attributes_userstorycustomattributesvalues'); + """ + ), + + # Trigger: Clean taskcustomattributes values before remove a taskcustomattribute + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS "update_taskcustomvalues_after_remove_taskcustomattribute" + ON custom_attributes_taskcustomattribute + CASCADE; + + CREATE TRIGGER "update_taskcustomvalues_after_remove_taskcustomattribute" + AFTER DELETE ON custom_attributes_taskcustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('task_id', 'tasks_task', + 'custom_attributes_taskcustomattributesvalues'); + """ + ), + + # Trigger: Clean issuecustomattributes values before remove a issuecustomattribute + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS "update_issuecustomvalues_after_remove_issuecustomattribute" + ON custom_attributes_issuecustomattribute + CASCADE; + + CREATE TRIGGER "update_issuecustomvalues_after_remove_issuecustomattribute" + AFTER DELETE ON custom_attributes_issuecustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('issue_id', 'issues_issue', + 'custom_attributes_issuecustomattributesvalues'); + """ + ), + migrations.AlterIndexTogether( + name='issuecustomattributesvalues', + index_together=set([('issue',)]), + ), + migrations.AlterIndexTogether( + name='taskcustomattributesvalues', + index_together=set([('task',)]), + ), + migrations.AlterIndexTogether( + name='userstorycustomattributesvalues', + index_together=set([('user_story',)]), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0009_auto_20160728_1002.py b/taiga/projects/custom_attributes/migrations/0009_auto_20160728_1002.py new file mode 100644 index 000000000..939bb5cb2 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0009_auto_20160728_1002.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-07-28 10:02 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('epics', '0002_epic_color'), + ('projects', '0050_project_epics_csv_uuid'), + ('custom_attributes', '0008_auto_20160728_0540'), + ] + + operations = [ + # Change some verbose names + migrations.AlterModelOptions( + name='issuecustomattributesvalues', + options={'ordering': ['id'], 'verbose_name': 'issue custom attributes values', 'verbose_name_plural': 'issue custom attributes values'}, + ), + migrations.AlterModelOptions( + name='taskcustomattributesvalues', + options={'ordering': ['id'], 'verbose_name': 'task custom attributes values', 'verbose_name_plural': 'task custom attributes values'}, + ), + migrations.AlterModelOptions( + name='userstorycustomattributesvalues', + options={'ordering': ['id'], 'verbose_name': 'user story custom attributes values', 'verbose_name_plural': 'user story custom attributes values'}, + ), + # Custom attributes for epics + migrations.CreateModel( + name='EpicCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64, verbose_name='name')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('type', models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date'), ('url', 'Url')], default='text', max_length=16, verbose_name='type')), + ('order', models.IntegerField(default=10000, verbose_name='order')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epiccustomattributes', to='projects.Project', verbose_name='project')), + ], + options={ + 'verbose_name': 'epic custom attribute', + 'abstract': False, + 'ordering': ['project', 'order', 'name'], + 'verbose_name_plural': 'epic custom attributes', + }, + ), + migrations.CreateModel( + name='EpicCustomAttributesValues', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', taiga.base.db.models.fields.JSONField(default={}, verbose_name='values')), + ('epic', models.OneToOneField(related_name='custom_attributes_values', to='epics.Epic', verbose_name='epic', on_delete=models.CASCADE)), + ], + options={ + 'abstract': False, + 'verbose_name': 'epic custom attributes values', + 'ordering': ['id'], + 'verbose_name_plural': 'epic custom attributes values', + }, + ), + migrations.AlterIndexTogether( + name='epiccustomattributesvalues', + index_together=set([('epic',)]), + ), + migrations.AlterUniqueTogether( + name='epiccustomattribute', + unique_together=set([('project', 'name')]), + ), + migrations.RunSQL( + """ + CREATE TRIGGER "update_epiccustomvalues_after_remove_epiccustomattribute" + AFTER DELETE ON custom_attributes_epiccustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('epic_id', 'epics_epic', + 'custom_attributes_epiccustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_epiccustomvalues_after_remove_epiccustomattribute" + ON custom_attributes_epiccustomattribute + CASCADE;""" + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0010_auto_20160928_0540.py b/taiga/projects/custom_attributes/migrations/0010_auto_20160928_0540.py new file mode 100644 index 000000000..b37631e3e --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0010_auto_20160928_0540.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-09-28 05:40 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.base.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0009_auto_20160728_1002'), + ] + + operations = [ + migrations.AlterField( + model_name='epiccustomattribute', + name='order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='order'), + ), + migrations.AlterField( + model_name='issuecustomattribute', + name='order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='order'), + ), + migrations.AlterField( + model_name='taskcustomattribute', + name='order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='order'), + ), + migrations.AlterField( + model_name='userstorycustomattribute', + name='order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='order'), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0011_json_to_jsonb.py b/taiga/projects/custom_attributes/migrations/0011_json_to_jsonb.py new file mode 100644 index 000000000..989a99882 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0011_json_to_jsonb.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.2 on 2016-10-26 11:34 +from __future__ import unicode_literals + +from django.db import migrations +from django.contrib.postgres.fields import JSONField + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0010_auto_20160928_0540'), + ] + + operations = [ + migrations.RunSQL( + """ + ALTER TABLE "{table_name}" + ALTER COLUMN "{column_name}" + TYPE jsonb + USING regexp_replace("{column_name}"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """.format( + table_name="custom_attributes_epiccustomattributesvalues", + column_name="attributes_values", + ), + reverse_sql=migrations.RunSQL.noop + ), + migrations.RunSQL( + """ + ALTER TABLE "{table_name}" + ALTER COLUMN "{column_name}" + TYPE jsonb + USING regexp_replace("{column_name}"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """.format( + table_name="custom_attributes_userstorycustomattributesvalues", + column_name="attributes_values", + ), + reverse_sql=migrations.RunSQL.noop + ), + migrations.RunSQL( + """ + ALTER TABLE "{table_name}" + ALTER COLUMN "{column_name}" + TYPE jsonb + USING regexp_replace("{column_name}"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """.format( + table_name="custom_attributes_taskcustomattributesvalues", + column_name="attributes_values", + ), + reverse_sql=migrations.RunSQL.noop + ), + migrations.RunSQL( + """ + ALTER TABLE "{table_name}" + ALTER COLUMN "{column_name}" + TYPE jsonb + USING regexp_replace("{column_name}"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """.format( + table_name="custom_attributes_issuecustomattributesvalues", + column_name="attributes_values", + ), + reverse_sql=migrations.RunSQL.noop + ), + + # Function: Remove a key in a json field + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" jsonb, VARIADIC "keys_to_delete" text[]) + RETURNS jsonb + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}') + FROM jsonb_each("json") + WHERE "key" <> ALL ("keys_to_delete")), + '{}')::text::jsonb $function$; + """, + reverse_sql=""" + CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + RETURNS json + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}') + FROM json_each("json") + WHERE "key" <> ALL ("keys_to_delete")), + '{}')::json $function$;""" + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0012_auto_20161201_1628.py b/taiga/projects/custom_attributes/migrations/0012_auto_20161201_1628.py new file mode 100644 index 000000000..3a3c5f068 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0012_auto_20161201_1628.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.3 on 2016-12-01 16:28 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0011_json_to_jsonb'), + ] + + 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')], 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')], 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')], 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')], default='text', max_length=16, verbose_name='type'), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0013_auto_20181022_1624.py b/taiga/projects/custom_attributes/migrations/0013_auto_20181022_1624.py new file mode 100644 index 000000000..6997fda09 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0013_auto_20181022_1624.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# 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'), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0014_auto_20181025_0711.py b/taiga/projects/custom_attributes/migrations/0014_auto_20181025_0711.py new file mode 100644 index 000000000..905c61c67 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0014_auto_20181025_0711.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# 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'), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0015_auto_20200615_0811.py b/taiga/projects/custom_attributes/migrations/0015_auto_20200615_0811.py new file mode 100644 index 000000000..c2188a4a7 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0015_auto_20200615_0811.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-15 08:11 + +import django.core.serializers.json +from django.db import migrations +import taiga.base.db.models.fields.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0014_auto_20181025_0711'), + ] + + operations = [ + migrations.AlterField( + model_name='epiccustomattributesvalues', + name='attributes_values', + field=taiga.base.db.models.fields.json.JSONField(default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder, verbose_name='values'), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/__init__.py b/taiga/projects/custom_attributes/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/custom_attributes/models.py b/taiga/projects/custom_attributes/models.py new file mode 100644 index 000000000..958e9bb83 --- /dev/null +++ b/taiga/projects/custom_attributes/models.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone + +from taiga.base.db.models.fields import JSONField + +from taiga.base.utils.time import timestamp_ms +from taiga.projects.occ.mixins import OCCModelMixin + +from . import choices + + +###################################################### +# Custom Attribute Models +####################################################### + +class AbstractCustomAttribute(models.Model): + name = models.CharField(null=False, blank=False, max_length=64, verbose_name=_("name")) + description = models.TextField(null=False, blank=True, verbose_name=_("description")) + type = models.CharField(null=False, blank=False, max_length=16, + choices=choices.TYPES_CHOICES, default=choices.TEXT_TYPE, + verbose_name=_("type")) + 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"), + on_delete=models.CASCADE, + ) + 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, + verbose_name=_("modified date")) + _importing = None + + class Meta: + abstract = True + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + return super().save(*args, **kwargs) + + +class EpicCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "epic custom attribute" + verbose_name_plural = "epic custom attributes" + + +class UserStoryCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "user story custom attribute" + verbose_name_plural = "user story custom attributes" + + +class TaskCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "task custom attribute" + verbose_name_plural = "task custom attributes" + + +class IssueCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "issue custom attribute" + verbose_name_plural = "issue custom attributes" + + +###################################################### +# Custom Attributes Values Models +####################################################### + +class AbstractCustomAttributesValues(OCCModelMixin, models.Model): + attributes_values = JSONField(null=False, blank=False, default=dict, verbose_name=_("values")) + + class Meta: + abstract = True + ordering = ["id"] + + +class EpicCustomAttributesValues(AbstractCustomAttributesValues): + epic = models.OneToOneField( + "epics.Epic", + null=False, + blank=False, + related_name="custom_attributes_values", + verbose_name=_("epic"), + on_delete=models.CASCADE, + ) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "epic custom attributes values" + verbose_name_plural = "epic custom attributes values" + index_together = [("epic",)] + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.epic.project + + +class UserStoryCustomAttributesValues(AbstractCustomAttributesValues): + user_story = models.OneToOneField( + "userstories.UserStory", + null=False, + blank=False, + related_name="custom_attributes_values", + verbose_name=_("user story"), + on_delete=models.CASCADE, + ) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "user story custom attributes values" + verbose_name_plural = "user story custom attributes values" + index_together = [("user_story",)] + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.user_story.project + + +class TaskCustomAttributesValues(AbstractCustomAttributesValues): + task = models.OneToOneField( + "tasks.Task", + null=False, + blank=False, + related_name="custom_attributes_values", + verbose_name=_("task"), + on_delete=models.CASCADE, + ) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "task custom attributes values" + verbose_name_plural = "task custom attributes values" + index_together = [("task",)] + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.task.project + + +class IssueCustomAttributesValues(AbstractCustomAttributesValues): + issue = models.OneToOneField( + "issues.Issue", + null=False, + blank=False, + related_name="custom_attributes_values", + verbose_name=_("issue"), + on_delete=models.CASCADE, + ) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "issue custom attributes values" + verbose_name_plural = "issue custom attributes values" + index_together = [("issue",)] + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.issue.project diff --git a/taiga/projects/custom_attributes/permissions.py b/taiga/projects/custom_attributes/permissions.py new file mode 100644 index 000000000..7f366ab84 --- /dev/null +++ b/taiga/projects/custom_attributes/permissions.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission +from taiga.base.api.permissions import HasProjectPerm +from taiga.base.api.permissions import IsProjectAdmin +from taiga.base.api.permissions import AllowAny +from taiga.base.api.permissions import IsSuperUser + + +###################################################### +# Custom Attribute Permissions +####################################################### + +class EpicCustomAttributePermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class UserStoryCustomAttributePermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class TaskCustomAttributePermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class IssueCustomAttributePermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +###################################################### +# Custom Attributes Values Permissions +####################################################### + +class EpicCustomAttributesValuesPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + update_perms = HasProjectPerm('modify_us') + partial_update_perms = HasProjectPerm('modify_us') + + +class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + update_perms = HasProjectPerm('modify_us') + partial_update_perms = HasProjectPerm('modify_us') + + +class TaskCustomAttributesValuesPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_tasks') + update_perms = HasProjectPerm('modify_task') + partial_update_perms = HasProjectPerm('modify_task') + + +class IssueCustomAttributesValuesPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_issues') + update_perms = HasProjectPerm('modify_issue') + partial_update_perms = HasProjectPerm('modify_issue') diff --git a/taiga/projects/custom_attributes/serializers.py b/taiga/projects/custom_attributes/serializers.py new file mode 100644 index 000000000..ea5a77def --- /dev/null +++ b/taiga/projects/custom_attributes/serializers.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.fields import JSONField, Field +from taiga.base.api import serializers + + +###################################################### +# Custom Attribute Serializer +####################################################### + +class BaseCustomAttributeSerializer(serializers.LightSerializer): + id = Field() + name = Field() + description = Field() + type = Field() + order = Field() + project = Field(attr="project_id") + extra = Field() + created_date = Field() + modified_date = Field() + + +class EpicCustomAttributeSerializer(BaseCustomAttributeSerializer): + pass + + +class UserStoryCustomAttributeSerializer(BaseCustomAttributeSerializer): + pass + + +class TaskCustomAttributeSerializer(BaseCustomAttributeSerializer): + pass + + +class IssueCustomAttributeSerializer(BaseCustomAttributeSerializer): + pass + + +###################################################### +# Custom Attribute Serializer +####################################################### +class BaseCustomAttributesValuesSerializer(serializers.LightSerializer): + attributes_values = Field() + version = Field() + + +class EpicCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): + epic = Field(attr="epic.id") + + +class UserStoryCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): + user_story = Field(attr="user_story.id") + + +class TaskCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): + task = Field(attr="task.id") + + +class IssueCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): + issue = Field(attr="issue.id") diff --git a/taiga/projects/custom_attributes/services.py b/taiga/projects/custom_attributes/services.py new file mode 100644 index 000000000..355f1e21a --- /dev/null +++ b/taiga/projects/custom_attributes/services.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import transaction +from django.db import connection + + +@transaction.atomic +def bulk_update_epic_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_epiccustomattribute set "order" = $1 + where custom_attributes_epiccustomattribute.id = $2 and + custom_attributes_epiccustomattribute.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_userstory_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_userstorycustomattribute set "order" = $1 + where custom_attributes_userstorycustomattribute.id = $2 and + custom_attributes_userstorycustomattribute.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_task_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_taskcustomattribute set "order" = $1 + where custom_attributes_taskcustomattribute.id = $2 and + custom_attributes_taskcustomattribute.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_issue_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_issuecustomattribute set "order" = $1 + where custom_attributes_issuecustomattribute.id = $2 and + custom_attributes_issuecustomattribute.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() diff --git a/taiga/projects/custom_attributes/signals.py b/taiga/projects/custom_attributes/signals.py new file mode 100644 index 000000000..f709149f2 --- /dev/null +++ b/taiga/projects/custom_attributes/signals.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from . import models + + +def create_custom_attribute_value_when_create_epic(sender, instance, created, **kwargs): + if created: + models.EpicCustomAttributesValues.objects.get_or_create(epic=instance, + defaults={"attributes_values":{}}) + + +def create_custom_attribute_value_when_create_user_story(sender, instance, created, **kwargs): + if created: + models.UserStoryCustomAttributesValues.objects.get_or_create(user_story=instance, + defaults={"attributes_values":{}}) + + +def create_custom_attribute_value_when_create_task(sender, instance, created, **kwargs): + if created: + models.TaskCustomAttributesValues.objects.get_or_create(task=instance, + defaults={"attributes_values":{}}) + + +def create_custom_attribute_value_when_create_issue(sender, instance, created, **kwargs): + if created: + models.IssueCustomAttributesValues.objects.get_or_create(issue=instance, + defaults={"attributes_values":{}}) diff --git a/taiga/projects/custom_attributes/validators.py b/taiga/projects/custom_attributes/validators.py new file mode 100644 index 000000000..79e6817ee --- /dev/null +++ b/taiga/projects/custom_attributes/validators.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext_lazy as _ + +from taiga.base.fields import JSONField +from taiga.base.exceptions import ValidationError +from taiga.base.api.validators import ModelValidator + +from . import models + + +###################################################### +# Custom Attribute Validator +####################################################### + +class BaseCustomAttributeValidator(ModelValidator): + class Meta: + read_only_fields = ('id',) + exclude = ('created_date', 'modified_date') + + def _validate_integrity_between_project_and_name(self, attrs, source): + """ + Check the name is not duplicated in the project. Check when: + - create a new one + - update the name + - update the project (move to another project) + """ + data_id = attrs.get("id", None) + data_name = attrs.get("name", None) + data_project = attrs.get("project", None) + + if self.object: + data_id = data_id or self.object.id + data_name = data_name or self.object.name + data_project = data_project or self.object.project + + model = self.Meta.model + qs = (model.objects.filter(project=data_project, name=data_name) + .exclude(id=data_id)) + if qs.exists(): + raise ValidationError(_("Already exists one with the same name.")) + + return attrs + + def validate_name(self, attrs, source): + return self._validate_integrity_between_project_and_name(attrs, source) + + def validate_project(self, attrs, source): + return self._validate_integrity_between_project_and_name(attrs, source) + + +class EpicCustomAttributeValidator(BaseCustomAttributeValidator): + class Meta(BaseCustomAttributeValidator.Meta): + model = models.EpicCustomAttribute + + +class UserStoryCustomAttributeValidator(BaseCustomAttributeValidator): + class Meta(BaseCustomAttributeValidator.Meta): + model = models.UserStoryCustomAttribute + + +class TaskCustomAttributeValidator(BaseCustomAttributeValidator): + class Meta(BaseCustomAttributeValidator.Meta): + model = models.TaskCustomAttribute + + +class IssueCustomAttributeValidator(BaseCustomAttributeValidator): + class Meta(BaseCustomAttributeValidator.Meta): + model = models.IssueCustomAttribute + + +###################################################### +# Custom Attribute Validator +####################################################### + + +class BaseCustomAttributesValuesValidator(ModelValidator): + attributes_values = JSONField(source="attributes_values", label="attributes values") + _custom_attribute_model = None + _container_field = None + + class Meta: + exclude = ("id",) + + def validate_attributes_values(self, attrs, source): + # values must be a dict + data_values = attrs.get("attributes_values", None) + if self.object: + data_values = (data_values or self.object.attributes_values) + + if type(data_values) is not dict: + raise ValidationError(_("Invalid content. It must be {\"key\": \"value\",...}")) + + # Values keys must be in the container object project + data_container = attrs.get(self._container_field, None) + if data_container: + project_id = data_container.project_id + elif self.object: + project_id = getattr(self.object, self._container_field).project_id + else: + project_id = None + + values_ids = list(data_values.keys()) + qs = self._custom_attribute_model.objects.filter(project=project_id, + id__in=values_ids) + if qs.count() != len(values_ids): + raise ValidationError(_("It contains invalid custom fields.")) + + return attrs + + +class EpicCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator): + _custom_attribute_model = models.EpicCustomAttribute + _container_model = "epics.Epic" + _container_field = "epic" + + class Meta(BaseCustomAttributesValuesValidator.Meta): + model = models.EpicCustomAttributesValues + + +class UserStoryCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator): + _custom_attribute_model = models.UserStoryCustomAttribute + _container_model = "userstories.UserStory" + _container_field = "user_story" + + class Meta(BaseCustomAttributesValuesValidator.Meta): + model = models.UserStoryCustomAttributesValues + + +class TaskCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator, ModelValidator): + _custom_attribute_model = models.TaskCustomAttribute + _container_field = "task" + + class Meta(BaseCustomAttributesValuesValidator.Meta): + model = models.TaskCustomAttributesValues + + +class IssueCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator, ModelValidator): + _custom_attribute_model = models.IssueCustomAttribute + _container_field = "issue" + + class Meta(BaseCustomAttributesValuesValidator.Meta): + model = models.IssueCustomAttributesValues diff --git a/taiga/projects/due_dates/__init__.py b/taiga/projects/due_dates/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/due_dates/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/due_dates/models.py b/taiga/projects/due_dates/models.py new file mode 100644 index 000000000..c53c6a78e --- /dev/null +++ b/taiga/projects/due_dates/models.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class DueDateMixin(models.Model): + due_date = models.DateField( + blank=True, null=True, default=None, verbose_name=_('due date'), + ) + due_date_reason = models.TextField( + null=False, blank=True, default='', verbose_name=_('reason for the due date'), + ) + + class Meta: + abstract = True diff --git a/taiga/projects/due_dates/serializers.py b/taiga/projects/due_dates/serializers.py new file mode 100644 index 000000000..de1f8aa0a --- /dev/null +++ b/taiga/projects/due_dates/serializers.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime as dt + +from django.utils import timezone + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField + + +class DueDateSerializerMixin(serializers.LightSerializer): + due_date = Field() + due_date_reason = Field() + due_date_status = MethodField() + + THRESHOLD = 14 + + def get_due_date_status(self, obj): + if obj.due_date is None: + return 'not_set' + elif obj.status and obj.status.is_closed: + return 'no_longer_applicable' + elif timezone.now().date() > obj.due_date: + return 'past_due' + elif (timezone.now().date() + dt.timedelta( + days=self.THRESHOLD)) >= obj.due_date: + return 'due_soon' + else: + return 'set' diff --git a/taiga/projects/epics/__init__.py b/taiga/projects/epics/__init__.py new file mode 100644 index 000000000..e6281a1af --- /dev/null +++ b/taiga/projects/epics/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + diff --git a/taiga/projects/epics/admin.py b/taiga/projects/epics/admin.py new file mode 100644 index 000000000..661a67d6d --- /dev/null +++ b/taiga/projects/epics/admin.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin + +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + +from . import models + + +class RelatedUserStoriesInline(admin.TabularInline): + model = models.RelatedUserStory + sortable_field_name = "order" + raw_id_fields = ["user_story", ] + extra = 0 + + +class EpicAdmin(admin.ModelAdmin): + list_display = ["project", "ref", "subject"] + list_display_links = ["ref", "subject"] + inlines = [WatchedInline, VoteInline, RelatedUserStoriesInline] + raw_id_fields = ["project"] + search_fields = ["subject", "description", "id", "ref"] + + def get_object(self, *args, **kwargs): + self.obj = super().get_object(*args, **kwargs) + return self.obj + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if (db_field.name in ["status"] and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter(project=self.obj.project) + + elif (db_field.name in ["owner", "assigned_to"] and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter(memberships__project=self.obj.project) + + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + if (db_field.name in ["watchers"] and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related.parent_model.objects.filter(memberships__project=self.obj.project) + return super().formfield_for_manytomany(db_field, request, **kwargs) + + +admin.site.register(models.Epic, EpicAdmin) diff --git a/taiga/projects/epics/api.py b/taiga/projects/epics/api.py new file mode 100644 index 000000000..31fb49bb0 --- /dev/null +++ b/taiga/projects/epics/api.py @@ -0,0 +1,297 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.http import HttpResponse +from django.utils.translation import gettext as _ + +from taiga.base.api.utils import get_object_or_error +from taiga.base import filters, response +from taiga.base import exceptions as exc +from taiga.base.decorators import list_route +from taiga.base.api import ModelCrudViewSet, ModelListViewSet +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.api.viewsets import NestedViewSetMixin +from taiga.base.utils import json + +from taiga.projects.history.mixins import HistoryResourceMixin +from taiga.projects.mixins.by_ref import ByRefMixin +from taiga.projects.models import Project, EpicStatus +from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin +from taiga.projects.occ import OCCResourceMixin +from taiga.projects.tagging.api import TaggedResourceMixin +from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin + +from django_pglocks import advisory_lock + +from . import models +from . import permissions +from . import serializers +from . import services +from . import validators +from . import utils as epics_utils + + +class EpicViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, + ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet): + validator_class = validators.EpicValidator + queryset = models.Epic.objects.all() + permission_classes = (permissions.EpicPermission,) + filter_backends = (filters.CanViewEpicsFilterBackend, + filters.OwnersFilter, + filters.AssignedToFilter, + filters.StatusesFilter, + filters.TagsFilter, + filters.WatchersFilter, + filters.QFilter, + filters.CreatedDateFilter, + filters.ModifiedDateFilter) + filter_fields = ["project", + "project__slug", + "assigned_to", + "status__is_closed"] + + def get_serializer_class(self, *args, **kwargs): + if self.action in ["retrieve", "by_ref"]: + return serializers.EpicNeighborsSerializer + + if self.action == "list": + return serializers.EpicListSerializer + + return serializers.EpicSerializer + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.select_related("project", + "status", + "owner", + "assigned_to") + + include_attachments = "include_attachments" in self.request.QUERY_PARAMS + qs = epics_utils.attach_extra_info(qs, user=self.request.user, + include_attachments=include_attachments) + + return qs + + def pre_conditions_on_save(self, obj): + super().pre_conditions_on_save(obj) + + if obj.status and obj.status.project != obj.project: + raise exc.WrongArguments(_("You don't have permissions to set this status to this epic.")) + + """ + Updating the epic order attribute can affect the ordering of another epics + This method generate a key for the epic and can be used to be compared before and after + saving + If there is any difference it means an extra ordering update must be done + """ + def _epics_order_key(self, obj): + return "{}-{}".format(obj.project_id, obj.epics_order) + + def pre_save(self, obj): + if not obj.id: + obj.owner = self.request.user + else: + self._old_epics_order_key = self._epics_order_key(self.get_object()) + + super().pre_save(obj) + + def _reorder_if_needed(self, obj, old_order_key, order_key): + # Executes the extra ordering if there is a difference in the ordering keys + if old_order_key == order_key: + return {} + + extra_orders = json.loads(self.request.headers.get("set-orders", "{}")) + data = [{"epic_id": obj.id, "order": getattr(obj, "epics_order")}] + for id, order in extra_orders.items(): + data.append({"epic_id": int(id), "order": order}) + + return services.update_epics_order_in_bulk(data, "epics_order", project=obj.project) + + def post_save(self, obj, created=False): + if not created: + # Let's reorder the related stuff after edit the element + orders_updated = self._reorder_if_needed(obj, + self._old_epics_order_key, + self._epics_order_key(obj)) + self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated) + + super().post_save(obj, created) + + def update(self, request, *args, **kwargs): + self.object = self.get_object_or_none() + project_id = request.DATA.get('project', None) + if project_id and self.object and self.object.project.id != project_id: + try: + new_project = Project.objects.get(pk=project_id) + self.check_permissions(request, "destroy", self.object) + self.check_permissions(request, "create", new_project) + + status_id = request.DATA.get('status', None) + if status_id is not None: + try: + old_status = self.object.project.epic_statuses.get(pk=status_id) + new_status = new_project.epic_statuses.get(slug=old_status.slug) + request.DATA['status'] = new_status.id + except EpicStatus.DoesNotExist: + request.DATA['status'] = new_project.default_epic_status.id + + except Project.DoesNotExist: + return response.BadRequest(_("The project doesn't exist")) + + return super().update(request, *args, **kwargs) + + @list_route(methods=["GET"]) + def filters_data(self, request, *args, **kwargs): + project_id = request.QUERY_PARAMS.get("project", None) + project = get_object_or_error(Project, request.user, id=project_id) + + filter_backends = self.get_filter_backends() + statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter) + assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter) + owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter) + + queryset = self.get_queryset() + querysets = { + "statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends), + "assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends), + "owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends), + "tags": self.filter_queryset(queryset) + } + return response.Ok(services.get_epics_filters_data(project, querysets)) + + @list_route(methods=["GET"]) + def csv(self, request): + uuid = request.QUERY_PARAMS.get("uuid", None) + if uuid is None: + return response.NotFound() + + project = get_object_or_error(Project, request.user, epics_csv_uuid=uuid) + queryset = project.epics.all().order_by('ref') + data = services.epics_to_csv(project, queryset) + csv_response = HttpResponse(data.getvalue(), content_type='application/csv; charset=utf-8') + csv_response['Content-Disposition'] = 'attachment; filename="epics.csv"' + return csv_response + + @list_route(methods=["POST"]) + def bulk_create(self, request, **kwargs): + validator = validators.EpicsBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + project = Project.objects.get(id=data["project_id"]) + self.check_permissions(request, "bulk_create", project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + epics = services.create_epics_in_bulk( + data["bulk_epics"], + status_id=data.get("status_id") or project.default_epic_status_id, + project=project, + owner=request.user, + callback=self.post_save, precall=self.pre_save) + + epics = self.get_queryset().filter(id__in=[i.id for i in epics]) + for epic in epics: + self.persist_history_snapshot(obj=epic) + + epics_serialized = self.get_serializer_class()(epics, many=True) + + return response.Ok(epics_serialized.data) + + +class EpicRelatedUserStoryViewSet(NestedViewSetMixin, HistoryResourceMixin, + BlockedByProjectMixin, ModelCrudViewSet): + queryset = models.RelatedUserStory.objects.all() + serializer_class = serializers.EpicRelatedUserStorySerializer + validator_class = validators.EpicRelatedUserStoryValidator + model = models.RelatedUserStory + permission_classes = (permissions.EpicRelatedUserStoryPermission,) + lookup_field = "user_story" + + """ + Updating the order attribute can affect the ordering of another userstories in the epic + This method generate a key for the userstory and can be used to be compared before and after + saving + If there is any difference it means an extra ordering update must be done + """ + def _order_key(self, obj): + return "{}-{}".format(obj.user_story.project_id, obj.order) + + def pre_save(self, obj): + if not obj.id: + obj.epic_id = self.kwargs["epic"] + else: + self._old_order_key = self._order_key(self.get_object()) + + super().pre_save(obj) + + def _reorder_if_needed(self, obj, old_order_key, order_key): + # Executes the extra ordering if there is a difference in the ordering keys + if old_order_key == order_key: + return {} + + extra_orders = json.loads(self.request.headers.get("set-orders", "{}")) + data = [{"us_id": obj.user_story.id, "order": getattr(obj, "order")}] + for id, order in extra_orders.items(): + data.append({"us_id": int(id), "order": order}) + + return services.update_epic_related_userstories_order_in_bulk(data, epic=obj.epic) + + def post_save(self, obj, created=False): + if not created: + # Let's reorder the related stuff after edit the element + orders_updated = self._reorder_if_needed(obj, + self._old_order_key, + self._order_key(obj)) + self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated) + + super().post_save(obj, created) + + def create(self, request, *args, **kwargs): + epic_id = request.DATA.get("epic", 0) + with advisory_lock("epic-related-user-stories-creation-{}".format(epic_id)): + return super().create(request, *args, **kwargs) + + @list_route(methods=["POST"]) + def bulk_create(self, request, **kwargs): + validator = validators.CreateRelatedUserStoriesBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + + epic = get_object_or_error(models.Epic, request.user, id=kwargs["epic"]) + project = Project.objects.get(pk=data.get('project_id')) + + self.check_permissions(request, 'bulk_create', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + related_userstories = services.create_related_userstories_in_bulk( + data["bulk_userstories"], + epic, + project=project, + owner=request.user + ) + + for related_userstory in related_userstories: + self.persist_history_snapshot(obj=related_userstory) + self.persist_history_snapshot(obj=related_userstory.user_story) + + related_uss_serialized = self.get_serializer_class()(epic.relateduserstory_set.all(), many=True) + return response.Ok(related_uss_serialized.data) + + +class EpicVotersViewSet(VotersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.EpicVotersPermission,) + resource_model = models.Epic + + +class EpicWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.EpicWatchersPermission,) + resource_model = models.Epic diff --git a/taiga/projects/epics/apps.py b/taiga/projects/epics/apps.py new file mode 100644 index 000000000..269060fcb --- /dev/null +++ b/taiga/projects/epics/apps.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig +from django.apps import apps +from django.db.models import signals + + +def connect_epics_signals(): + from taiga.projects.tagging import signals as tagging_handlers + + # Tags + signals.pre_save.connect(tagging_handlers.tags_normalization, + sender=apps.get_model("epics", "Epic"), + dispatch_uid="tags_normalization_epic") + + +def connect_epics_custom_attributes_signals(): + from taiga.projects.custom_attributes import signals as custom_attributes_handlers + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_epic, + sender=apps.get_model("epics", "Epic"), + dispatch_uid="create_custom_attribute_value_when_create_epic") + + +def connect_all_epics_signals(): + connect_epics_signals() + connect_epics_custom_attributes_signals() + + +def disconnect_epics_signals(): + signals.pre_save.disconnect(sender=apps.get_model("epics", "Epic"), + dispatch_uid="tags_normalization") + + +def disconnect_epics_custom_attributes_signals(): + signals.post_save.disconnect(sender=apps.get_model("epics", "Epic"), + dispatch_uid="create_custom_attribute_value_when_create_epic") + + +def disconnect_all_epics_signals(): + disconnect_epics_signals() + disconnect_epics_custom_attributes_signals() + + +class EpicsAppConfig(AppConfig): + name = "taiga.projects.epics" + verbose_name = "Epics" + + def ready(self): + connect_all_epics_signals() diff --git a/taiga/projects/epics/migrations/0001_initial.py b/taiga/projects/epics/migrations/0001_initial.py new file mode 100644 index 000000000..4356ac57e --- /dev/null +++ b/taiga/projects/epics/migrations/0001_initial.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-07-05 11:12 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import taiga.projects.notifications.mixins + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('userstories', '0012_auto_20160614_1201'), + ('projects', '0049_auto_20160629_1443'), + ('history', '0012_auto_20160629_1036'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Epic', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=list, null=True, size=None, verbose_name='tags')), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('is_blocked', models.BooleanField(default=False, verbose_name='is blocked')), + ('blocked_note', models.TextField(blank=True, default='', verbose_name='blocked note')), + ('ref', models.BigIntegerField(blank=True, db_index=True, default=None, null=True, verbose_name='ref')), + ('epics_order', models.IntegerField(default=10000, verbose_name='epics order')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('subject', models.TextField(verbose_name='subject')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('client_requirement', models.BooleanField(default=False, verbose_name='is client requirement')), + ('team_requirement', models.BooleanField(default=False, verbose_name='is team requirement')), + ('assigned_to', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='epics_assigned_to_me', to=settings.AUTH_USER_MODEL, verbose_name='assigned to')), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_epics', to=settings.AUTH_USER_MODEL, verbose_name='owner')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epics', to='projects.Project', verbose_name='project')), + ('status', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='epics', to='projects.EpicStatus', verbose_name='status')), + ], + options={ + 'ordering': ['project', 'epics_order', 'ref'], + 'verbose_name_plural': 'epics', + 'verbose_name': 'epic', + }, + bases=(taiga.projects.notifications.mixins.WatchedModelMixin, models.Model), + ), + migrations.CreateModel( + name='RelatedUserStory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.IntegerField(default=10000, verbose_name='order')), + ('epic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epics.Epic')), + ('user_story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='userstories.UserStory')), + ], + options={ + 'ordering': ['user_story', 'order', 'id'], + 'verbose_name_plural': 'related user stories', + 'verbose_name': 'related user story', + }, + ), + migrations.AddField( + model_name='epic', + name='user_stories', + field=models.ManyToManyField(related_name='epics', through='epics.RelatedUserStory', to='userstories.UserStory', verbose_name='user stories'), + ), + # Execute trigger after epic update + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_epic_update ON epics_epic; + CREATE TRIGGER update_project_tags_colors_on_epic_update + AFTER UPDATE ON epics_epic + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after epic insert + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_epic_insert ON epics_epic; + CREATE TRIGGER update_project_tags_colors_on_epic_insert + AFTER INSERT ON epics_epic + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + ] diff --git a/taiga/projects/epics/migrations/0002_epic_color.py b/taiga/projects/epics/migrations/0002_epic_color.py new file mode 100644 index 000000000..ce5945493 --- /dev/null +++ b/taiga/projects/epics/migrations/0002_epic_color.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-07-27 09:37 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.base.utils.colors + + +class Migration(migrations.Migration): + + dependencies = [ + ('epics', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='epic', + name='color', + field=models.CharField(blank=True, default=taiga.base.utils.colors.generate_random_predefined_hex_color, max_length=32, verbose_name='color'), + ), + ] diff --git a/taiga/projects/epics/migrations/0003_auto_20160901_1021.py b/taiga/projects/epics/migrations/0003_auto_20160901_1021.py new file mode 100644 index 000000000..86d5283c2 --- /dev/null +++ b/taiga/projects/epics/migrations/0003_auto_20160901_1021.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-09-01 10:21 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('epics', '0002_epic_color'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='relateduserstory', + unique_together=set([('user_story', 'epic')]), + ), + ] diff --git a/taiga/projects/epics/migrations/0004_auto_20160928_0540.py b/taiga/projects/epics/migrations/0004_auto_20160928_0540.py new file mode 100644 index 000000000..06f715657 --- /dev/null +++ b/taiga/projects/epics/migrations/0004_auto_20160928_0540.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-09-28 05:40 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.base.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ('epics', '0003_auto_20160901_1021'), + ] + + operations = [ + migrations.AlterField( + model_name='epic', + name='epics_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='epics order'), + ), + migrations.AlterField( + model_name='relateduserstory', + name='order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='order'), + ), + ] diff --git a/taiga/projects/epics/migrations/0005_epic_external_reference.py b/taiga/projects/epics/migrations/0005_epic_external_reference.py new file mode 100644 index 000000000..4404ddd4e --- /dev/null +++ b/taiga/projects/epics/migrations/0005_epic_external_reference.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-11-08 11:19 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epics', '0004_auto_20160928_0540'), + ] + + operations = [ + migrations.AddField( + model_name='epic', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=None, null=True, size=None, verbose_name='external reference'), + ), + ] diff --git a/taiga/projects/epics/migrations/0006_auto_20200615_0811.py b/taiga/projects/epics/migrations/0006_auto_20200615_0811.py new file mode 100644 index 000000000..c917998cc --- /dev/null +++ b/taiga/projects/epics/migrations/0006_auto_20200615_0811.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-15 08:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('epics', '0005_epic_external_reference'), + ] + + operations = [ + migrations.AlterField( + model_name='epic', + name='assigned_to', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='epics_assigned_to_me', to=settings.AUTH_USER_MODEL, verbose_name='assigned to'), + ), + migrations.AlterField( + model_name='epic', + name='client_requirement', + field=models.BooleanField(blank=True, default=False, verbose_name='is client requirement'), + ), + migrations.AlterField( + model_name='epic', + name='is_blocked', + field=models.BooleanField(blank=True, default=False, verbose_name='is blocked'), + ), + migrations.AlterField( + model_name='epic', + name='team_requirement', + field=models.BooleanField(blank=True, default=False, verbose_name='is team requirement'), + ), + ] diff --git a/taiga/projects/epics/migrations/__init__.py b/taiga/projects/epics/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/epics/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/epics/models.py b/taiga/projects/epics/models.py new file mode 100644 index 000000000..b4c28137b --- /dev/null +++ b/taiga/projects/epics/models.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone + +from taiga.base.utils.colors import generate_random_predefined_hex_color +from taiga.base.utils.time import timestamp_ms +from taiga.projects.tagging.models import TaggedMixin +from taiga.projects.occ import OCCModelMixin +from taiga.projects.notifications.mixins import WatchedModelMixin +from taiga.projects.mixins.blocked import BlockedMixin + + +class Epic(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model): + ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, + verbose_name=_("ref")) + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="epics", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + related_name="owned_epics", + verbose_name=_("owner"), + on_delete=models.SET_NULL + ) + status = models.ForeignKey("projects.EpicStatus", null=True, blank=True, + related_name="epics", verbose_name=_("status"), + on_delete=models.SET_NULL) + epics_order = models.BigIntegerField(null=False, blank=False, default=timestamp_ms, + verbose_name=_("epics order")) + + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + + subject = models.TextField(null=False, blank=False, + verbose_name=_("subject")) + description = models.TextField(null=False, blank=True, verbose_name=_("description")) + color = models.CharField(max_length=32, null=False, blank=True, + default=generate_random_predefined_hex_color, + verbose_name=_("color")) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=True, + null=True, + default=None, + related_name="epics_assigned_to_me", + verbose_name=_("assigned to"), + on_delete=models.SET_NULL, + ) + client_requirement = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is client requirement")) + team_requirement = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is team requirement")) + + user_stories = models.ManyToManyField("userstories.UserStory", related_name="epics", + through='RelatedUserStory', + verbose_name=_("user stories")) + external_reference = ArrayField(models.TextField(null=False, blank=False), + null=True, blank=True, default=None, verbose_name=_("external reference")) + + attachments = GenericRelation("attachments.Attachment") + + _importing = None + + class Meta: + verbose_name = "epic" + verbose_name_plural = "epics" + ordering = ["project", "epics_order", "ref"] + + def __str__(self): + return "#{0} {1}".format(self.ref, self.subject) + + def __repr__(self): + return "" % (self.id) + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + if not self.status: + self.status = self.project.default_epic_status + + super().save(*args, **kwargs) + + +class RelatedUserStory(WatchedModelMixin, models.Model): + user_story = models.ForeignKey("userstories.UserStory", on_delete=models.CASCADE) + epic = models.ForeignKey("epics.Epic", on_delete=models.CASCADE) + + order = models.BigIntegerField(null=False, blank=False, default=timestamp_ms, + verbose_name=_("order")) + + class Meta: + verbose_name = "related user story" + verbose_name_plural = "related user stories" + ordering = ["user_story", "order", "id"] + unique_together = (("user_story", "epic"), ) + + def __str__(self): + return "{0} - {1}".format(self.epic_id, self.user_story_id) + + @property + def project(self): + return self.epic.project + + @property + def project_id(self): + return self.epic.project_id + + @property + def owner(self): + return self.epic.owner + + @property + def owner_id(self): + return self.epic.owner_id + + @property + def assigned_to_id(self): + return self.epic.assigned_to_id diff --git a/taiga/projects/epics/permissions.py b/taiga/projects/epics/permissions.py new file mode 100644 index 000000000..6ef6c5f97 --- /dev/null +++ b/taiga/projects/epics/permissions.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsAuthenticated +from taiga.base.api.permissions import IsSuperUser, HasProjectPerm, IsProjectAdmin + +from taiga.permissions.permissions import CommentAndOrUpdatePerm + + +class EpicPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_epics') + create_perms = HasProjectPerm('add_epic') + update_perms = CommentAndOrUpdatePerm('modify_epic', 'comment_epic') + partial_update_perms = CommentAndOrUpdatePerm('modify_epic', 'comment_epic') + destroy_perms = HasProjectPerm('delete_epic') + list_perms = AllowAny() + filters_data_perms = AllowAny() + csv_perms = AllowAny() + bulk_create_perms = HasProjectPerm('add_epic') + upvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') + downvote_perms = IsAuthenticated() & HasProjectPerm('view_epics') + watch_perms = IsAuthenticated() & HasProjectPerm('view_epics') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_epics') + + +class EpicRelatedUserStoryPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_epics') + create_perms = HasProjectPerm('modify_epic') + update_perms = HasProjectPerm('modify_epic') + partial_update_perms = HasProjectPerm('modify_epic') + destroy_perms = HasProjectPerm('modify_epic') + list_perms = AllowAny() + bulk_create_perms = HasProjectPerm('modify_epic') + + +class EpicVotersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_epics') + list_perms = HasProjectPerm('view_epics') + + +class EpicWatchersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_epics') + list_perms = HasProjectPerm('view_epics') diff --git a/taiga/projects/epics/serializers.py b/taiga/projects/epics/serializers.py new file mode 100644 index 000000000..7593c5493 --- /dev/null +++ b/taiga/projects/epics/serializers.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.base.neighbors import NeighborsSerializerMixin + +from taiga.mdrender.service import render as mdrender +from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer +from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin + + +class EpicListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, + OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, ProjectExtraInfoSerializerMixin, + BasicAttachmentsInfoSerializerMixin, + TaggedInProjectResourceSerializer, serializers.LightSerializer): + + id = Field() + ref = Field() + project = Field(attr="project_id") + created_date = Field() + modified_date = Field() + subject = Field() + color = Field() + epics_order = Field() + client_requirement = Field() + team_requirement = Field() + version = Field() + watchers = Field() + is_blocked = Field() + blocked_note = Field() + is_closed = MethodField() + user_stories_counts = MethodField() + + def get_is_closed(self, obj): + return obj.status is not None and obj.status.is_closed + + def get_user_stories_counts(self, obj): + assert hasattr(obj, "user_stories_counts"), "instance must have a user_stories_counts attribute" + return obj.user_stories_counts + + +class EpicSerializer(EpicListSerializer): + comment = MethodField() + blocked_note_html = MethodField() + description = Field() + description_html = MethodField() + + def get_comment(self, obj): + return "" + + def get_blocked_note_html(self, obj): + return mdrender(obj.project, obj.blocked_note) + + def get_description_html(self, obj): + return mdrender(obj.project, obj.description) + + +class EpicNeighborsSerializer(NeighborsSerializerMixin, EpicSerializer): + pass + + +class EpicRelatedUserStorySerializer(serializers.LightSerializer): + epic = Field(attr="epic_id") + user_story = Field(attr="user_story_id") + order = Field() diff --git a/taiga/projects/epics/services.py b/taiga/projects/epics/services.py new file mode 100644 index 000000000..beed00c17 --- /dev/null +++ b/taiga/projects/epics/services.py @@ -0,0 +1,432 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import csv +import io +from collections import OrderedDict +from operator import itemgetter +from contextlib import closing + +from django.db import connection +from django.utils.translation import gettext as _ + +from taiga.base.utils import db, text +from taiga.projects.epics.apps import connect_epics_signals +from taiga.projects.epics.apps import disconnect_epics_signals +from taiga.projects.services import apply_order_updates +from taiga.projects.userstories.apps import connect_userstories_signals +from taiga.projects.userstories.apps import disconnect_userstories_signals +from taiga.projects.userstories.services import get_userstories_from_bulk +from taiga.events import events +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.notifications.utils import attach_watchers_to_queryset + +from . import models + + +##################################################### +# Bulk actions +##################################################### + +def get_epics_from_bulk(bulk_data, **additional_fields): + """Convert `bulk_data` into a list of epics. + + :param bulk_data: List of epics in bulk format. + :param additional_fields: Additional fields when instantiating each epic. + + :return: List of `Epic` instances. + """ + return [models.Epic(subject=line, **additional_fields) + for line in text.split_in_lines(bulk_data)] + + +def create_epics_in_bulk(bulk_data, callback=None, precall=None, **additional_fields): + """Create epics from `bulk_data`. + + :param bulk_data: List of epics in bulk format. + :param callback: Callback to execute after each epic save. + :param additional_fields: Additional fields when instantiating each epic. + + :return: List of created `Epic` instances. + """ + epics = get_epics_from_bulk(bulk_data, **additional_fields) + + disconnect_epics_signals() + + try: + db.save_in_bulk(epics, callback, precall) + finally: + connect_epics_signals() + + return epics + + +def update_epics_order_in_bulk(bulk_data: list, field: str, project: object): + """ + Update the order of some epics. + `bulk_data` should be a list of tuples with the following format: + + [{'epic_id': , 'order': }, ...] + """ + epics = project.epics.all() + + epic_orders = {e.id: getattr(e, field) for e in epics} + new_epic_orders = {d["epic_id"]: d["order"] for d in bulk_data} + apply_order_updates(epic_orders, new_epic_orders) + + epic_ids = epic_orders.keys() + events.emit_event_for_ids(ids=epic_ids, + content_type="epics.epic", + projectid=project.pk) + + db.update_attr_in_bulk_for_ids(epic_orders, field, models.Epic) + return epic_orders + + +def create_related_userstories_in_bulk(bulk_data, epic, **additional_fields): + """Create user stories from `bulk_data`. + + :param epic: Element where all the user stories will be contained + :param bulk_data: List of user stories in bulk format. + :param additional_fields: Additional fields when instantiating each user story. + + :return: List of created `Task` instances. + """ + userstories = get_userstories_from_bulk(bulk_data, **additional_fields) + project = additional_fields.get("project") + + # Set default swimlane if kanban module is enabled + if project.is_kanban_activated: + for user_story in userstories: + user_story.swimlane = project.default_swimlane + + disconnect_userstories_signals() + + try: + db.save_in_bulk(userstories) + related_userstories = [] + for userstory in userstories: + related_userstories.append( + models.RelatedUserStory( + user_story=userstory, + epic=epic + ) + ) + db.save_in_bulk(related_userstories) + project.update_role_points(user_stories=userstories) + finally: + connect_userstories_signals() + + return related_userstories + + +def update_epic_related_userstories_order_in_bulk(bulk_data: list, epic: object): + """ + Updates the order of the related userstories of an specific epic. + `bulk_data` should be a list of dicts with the following format: + `epic` is the epic with related stories. + + [{'us_id': , 'order': }, ...] + """ + related_user_stories = epic.relateduserstory_set.all() + # select_related + rus_orders = {rus.id: rus.order for rus in related_user_stories} + + rus_conversion = {rus.user_story_id: rus.id for rus in related_user_stories} + new_rus_orders = {rus_conversion[e["us_id"]]: e["order"] for e in bulk_data + if e["us_id"] in rus_conversion} + + apply_order_updates(rus_orders, new_rus_orders) + + if rus_orders: + related_user_story_ids = rus_orders.keys() + events.emit_event_for_ids(ids=related_user_story_ids, + content_type="epics.relateduserstory", + projectid=epic.project_id) + + db.update_attr_in_bulk_for_ids(rus_orders, "order", models.RelatedUserStory) + + return rus_orders + + +##################################################### +# CSV +##################################################### + +def epics_to_csv(project, queryset): + csv_data = io.StringIO() + fieldnames = ["id", "ref", "subject", "description", "owner", "owner_full_name", + "assigned_to", "assigned_to_full_name", "status", "epics_order", + "client_requirement", "team_requirement", "attachments", "tags", + "watchers", "voters", "created_date", "modified_date", + "related_user_stories"] + + custom_attrs = project.epiccustomattributes.all() + for custom_attr in custom_attrs: + fieldnames.append(custom_attr.name) + + queryset = queryset.prefetch_related("attachments", + "custom_attributes_values", + "user_stories__project") + queryset = queryset.select_related("owner", + "assigned_to", + "status", + "project") + + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + + writer = csv.DictWriter(csv_data, fieldnames=fieldnames) + writer.writeheader() + for epic in queryset: + epic_data = { + "id": epic.id, + "ref": epic.ref, + "subject": epic.subject, + "description": epic.description, + "owner": epic.owner.username if epic.owner else None, + "owner_full_name": epic.owner.get_full_name() if epic.owner else None, + "assigned_to": epic.assigned_to.username if epic.assigned_to else None, + "assigned_to_full_name": epic.assigned_to.get_full_name() if epic.assigned_to else None, + "status": epic.status.name if epic.status else None, + "epics_order": epic.epics_order, + "client_requirement": epic.client_requirement, + "team_requirement": epic.team_requirement, + "attachments": epic.attachments.count(), + "tags": ",".join(epic.tags or []), + "watchers": epic.watchers, + "voters": epic.total_voters, + "created_date": epic.created_date, + "modified_date": epic.modified_date, + "related_user_stories": ",".join([ + "{}#{}".format(us.project.slug, us.ref) for us in epic.user_stories.all() + ]), + } + + for custom_attr in custom_attrs: + if not hasattr(epic, "custom_attributes_values"): + continue + value = epic.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) + epic_data[custom_attr.name] = value + + writer.writerow(epic_data) + + return csv_data + + +##################################################### +# Api filter data +##################################################### + +def _get_epics_statuses(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + SELECT "projects_epicstatus"."id", + "projects_epicstatus"."name", + "projects_epicstatus"."color", + "projects_epicstatus"."order", + (SELECT count(*) + FROM "epics_epic" + INNER JOIN "projects_project" ON + ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} AND "epics_epic"."status_id" = "projects_epicstatus"."id") + FROM "projects_epicstatus" + WHERE "projects_epicstatus"."project_id" = %s + ORDER BY "projects_epicstatus"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, color, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": color, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_epics_assigned_to(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT assigned_to_id, count(assigned_to_id) count + FROM "epics_epic" + INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} AND "epics_epic"."assigned_to_id" IS NOT NULL + GROUP BY assigned_to_id + ) + + SELECT "projects_membership"."user_id" user_id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."assigned_to_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s + AND "projects_membership"."user_id" IS NOT NULL + + -- unassigned epics + UNION + + SELECT NULL user_id, NULL, NULL, count(coalesce(assigned_to_id, -1)) count + FROM "epics_epic" + INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} AND "epics_epic"."assigned_to_id" IS NULL + GROUP BY assigned_to_id + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id] + where_params) + rows = cursor.fetchall() + + result = [] + none_valued_added = False + for id, full_name, username, count in rows: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + + if id is None: + none_valued_added = True + + # If there was no epic with null assigned_to we manually add it + if not none_valued_added: + result.append({ + "id": None, + "full_name": "", + "count": 0, + }) + + return sorted(result, key=itemgetter("full_name")) + + +def _get_epics_owners(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT "epics_epic"."owner_id" owner_id, + count(coalesce("epics_epic"."owner_id", -1)) count + FROM "epics_epic" + INNER JOIN "projects_project" ON ("epics_epic"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY "epics_epic"."owner_id" + ) + + SELECT "projects_membership"."user_id" id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s + AND "projects_membership"."user_id" IS NOT NULL + + -- System users + UNION + + SELECT "users_user"."id" user_id, + "users_user"."full_name" full_name, + "users_user"."username" username, + COALESCE("counters".count, 0) count + FROM users_user + LEFT OUTER JOIN counters ON ("users_user"."id" = "counters"."owner_id") + WHERE ("users_user"."is_system" IS TRUE) + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, full_name, username, count in rows: + if count > 0: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + return sorted(result, key=itemgetter("full_name")) + + +def _get_epics_tags(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH epics_tags AS ( + SELECT tag, + COUNT(tag) counter FROM ( + SELECT UNNEST(epics_epic.tags) tag + FROM epics_epic + INNER JOIN projects_project + ON (epics_epic.project_id = projects_project.id) + WHERE {where}) tags + GROUP BY tag), + project_tags AS ( + SELECT reduce_dim(tags_colors) tag_color + FROM projects_project + WHERE id=%s) + + SELECT tag_color[1] tag, + tag_color[2] color, + COALESCE(epics_tags.counter, 0) counter + FROM project_tags + LEFT JOIN epics_tags ON project_tags.tag_color[1] = epics_tags.tag + ORDER BY tag + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for name, color, count in rows: + result.append({ + "name": name, + "color": color, + "count": count, + }) + return sorted(result, key=itemgetter("name")) + + +def get_epics_filters_data(project, querysets): + """ + Given a project and an epics queryset, return a simple data structure + of all possible filters for the epics in the queryset. + """ + data = OrderedDict([ + ("statuses", _get_epics_statuses(project, querysets["statuses"])), + ("assigned_to", _get_epics_assigned_to(project, querysets["assigned_to"])), + ("owners", _get_epics_owners(project, querysets["owners"])), + ("tags", _get_epics_tags(project, querysets["tags"])), + ]) + + return data diff --git a/taiga/projects/epics/utils.py b/taiga/projects/epics/utils.py new file mode 100644 index 000000000..7152228e8 --- /dev/null +++ b/taiga/projects/epics/utils.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.projects.attachments.utils import attach_basic_attachments +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False): + if include_attachments: + queryset = attach_basic_attachments(queryset) + queryset = queryset.extra(select={"include_attachments": "True"}) + + queryset = attach_user_stories_counts_to_queryset(queryset) + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset + + +def attach_user_stories_counts_to_queryset(queryset, as_field="user_stories_counts"): + model = queryset.model + sql = """ + SELECT json_build_object('total', count(t.*), 'progress', sum(t.percentaje_completed)) + FROM( + SELECT + --userstories_userstory.id as userstories_userstory_id, + --userstories_userstory.is_closed as userstories_userstory_is_closed, + CASE WHEN userstories_userstory.is_closed + THEN 1 + ELSE + COALESCE(COUNT(tasks_task.id) FILTER (WHERE projects_taskstatus.is_closed = TRUE)::real / NULLIF(COUNT(tasks_task.id), 0),0)--, + END AS percentaje_completed + + FROM epics_relateduserstory + INNER JOIN userstories_userstory ON epics_relateduserstory.user_story_id = userstories_userstory.id + LEFT JOIN tasks_task ON tasks_task.user_story_id = userstories_userstory.id + LEFT JOIN projects_taskstatus ON tasks_task.status_id = projects_taskstatus.id + WHERE epics_relateduserstory.epic_id = {tbl}.id + GROUP BY userstories_userstory.id) t""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset diff --git a/taiga/projects/epics/validators.py b/taiga/projects/epics/validators.py new file mode 100644 index 000000000..cee85a2bd --- /dev/null +++ b/taiga/projects/epics/validators.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext 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.mixins.validators import AssignedToValidator +from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer +from taiga.projects.notifications.validators import WatchersValidator +from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.validators import ProjectExistsValidator +from . import models + + +class EpicExistsValidator: + def validate_epic_id(self, attrs, source): + value = attrs[source] + if not models.Epic.objects.filter(pk=value).exists(): + msg = _("There's no epic with that id") + raise ValidationError(msg) + return attrs + + +class EpicValidator(AssignedToValidator, WatchersValidator, EditableWatchedResourceSerializer, + validators.ModelValidator): + tags = TagsAndTagsColorsField(default=[], required=False) + external_reference = PgArrayField(required=False) + + class Meta: + model = models.Epic + read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') + + +class EpicsBulkValidator(ProjectExistsValidator, EpicExistsValidator, + validators.Validator): + project_id = serializers.IntegerField() + status_id = serializers.IntegerField(required=False) + bulk_epics = serializers.CharField() + + +class CreateRelatedUserStoriesBulkValidator(ProjectExistsValidator, EpicExistsValidator, + validators.Validator): + project_id = serializers.IntegerField() + bulk_userstories = serializers.CharField() + + +class EpicRelatedUserStoryValidator(validators.ModelValidator): + class Meta: + model = models.RelatedUserStory + read_only_fields = ('id',) diff --git a/taiga/projects/filters.py b/taiga/projects/filters.py new file mode 100644 index 000000000..1ac47d790 --- /dev/null +++ b/taiga/projects/filters.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import logging + +from django.apps import apps +from django.db.models import Q +from django.utils.translation import gettext as _ + +from taiga.base import exceptions as exc +from taiga.base.filters import FilterBackend +from taiga.base.filters import get_filter_expression_can_view_projects +from taiga.base.utils.db import to_tsquery + +logger = logging.getLogger(__name__) + + +class DiscoverModeFilterBackend(FilterBackend): + def filter_queryset(self, request, queryset, view): + qs = queryset + + if "discover_mode" in request.QUERY_PARAMS: + field_data = request.QUERY_PARAMS["discover_mode"] + discover_mode = self._special_values_dict.get(field_data, field_data) + + if discover_mode: + # discover_mode enabled + qs = qs.filter(anon_permissions__contains=["view_project"], + blocked_code__isnull=True) + + # random order for featured projects + if request.QUERY_PARAMS.get("is_featured", None) == 'true': + qs = qs.order_by("?") + + return super().filter_queryset(request, qs, view) + + +class CanViewProjectObjFilterBackend(FilterBackend): + def filter_queryset(self, request, queryset, view): + project_id = None + + # Filter by filter_fields + if (hasattr(view, "filter_fields") and "project" in view.filter_fields and + "project" in request.QUERY_PARAMS): + try: + project_id = int(request.QUERY_PARAMS["project"]) + except: + logger.error("Filtering project diferent value than an integer: {}".format( + request.QUERY_PARAMS["project"] + )) + raise exc.BadRequest(_("'project' must be an integer value.")) + + filter_expression = get_filter_expression_can_view_projects( + request.user, + project_id) + + qs = queryset.filter(filter_expression) + + return super().filter_queryset(request, qs, view) + + +class QFilterBackend(FilterBackend): + def filter_queryset(self, request, queryset, view): + # NOTE: See migtration 0033_text_search_indexes + q = request.QUERY_PARAMS.get('q', None) + if q: + tsquery = "to_tsquery('simple', %s)" + tsquery_params = [to_tsquery(q)] + tsvector = """ + setweight(to_tsvector('simple', + coalesce(projects_project.name, '')), 'A') || + setweight(to_tsvector('simple', + coalesce(inmutable_array_to_string(projects_project.tags), '')), 'B') || + setweight(to_tsvector('simple', + coalesce(projects_project.description, '')), 'C') + """ + + select = { + "rank": "ts_rank({tsvector},{tsquery})".format(tsquery=tsquery, + tsvector=tsvector), + } + select_params = tsquery_params + where = ["{tsvector} @@ {tsquery}".format(tsquery=tsquery, + tsvector=tsvector), ] + params = tsquery_params + order_by = ["-rank", ] + + queryset = queryset.extra(select=select, + select_params=select_params, + where=where, + params=params, + order_by=order_by) + return queryset + + +class UserOrderFilterBackend(FilterBackend): + def filter_queryset(self, request, queryset, view): + if request.user.is_anonymous: + return queryset + + raw_fieldname = request.QUERY_PARAMS.get(self.order_by_query_param, None) + if not raw_fieldname: + return queryset + + if raw_fieldname.startswith("-"): + field_name = raw_fieldname[1:] + else: + field_name = raw_fieldname + + if field_name != "user_order": + return queryset + + model = queryset.model + sql = """SELECT projects_membership.user_order + FROM projects_membership + WHERE + projects_membership.project_id = {tbl}.id AND + projects_membership.user_id = {user_id} + """ + + sql = sql.format(tbl=model._meta.db_table, user_id=request.user.id) + queryset = queryset.extra(select={"user_order": sql}) + queryset = queryset.order_by(raw_fieldname) + return queryset diff --git a/taiga/projects/fixtures/initial_project_templates.json b/taiga/projects/fixtures/initial_project_templates.json new file mode 100644 index 000000000..28f538f32 --- /dev/null +++ b/taiga/projects/fixtures/initial_project_templates.json @@ -0,0 +1,1284 @@ +[ +{ + "model": "projects.projecttemplate", + "pk": 1, + "fields": { + "name": "Scrum", + "slug": "scrum", + "description": "The agile product backlog in Scrum is a prioritized features list, containing short descriptions of all functionality desired in the product. When applying Scrum, it's not necessary to start a project with a lengthy, upfront effort to document all requirements. The Scrum product backlog is then allowed to grow and change as more is learned about the product and its customers", + "order": 1, + "created_date": "2014-04-22T14:48:43.596Z", + "modified_date": "2016-08-24T16:26:40.845Z", + "default_owner_role": "product-owner", + "is_epics_activated": false, + "is_backlog_activated": true, + "is_kanban_activated": false, + "is_wiki_activated": true, + "is_issues_activated": true, + "videoconferences": null, + "videoconferences_extra_data": "", + "default_options": { + "points": "?", + "priority": "Normal", + "us_status": "New", + "issue_type": "Bug", + "epic_status": "New", + "severity": "Normal", + "task_status": "New", + "issue_status": "New" + }, + "epic_statuses": [ + { + "slug": "new", + "color": "#70728F", + "name": "New", + "order": 1, + "is_closed": false + }, + { + "slug": "ready", + "color": "#E44057", + "name": "Ready", + "order": 2, + "is_closed": false + }, + { + "slug": "in-progress", + "color": "#E47C40", + "name": "In progress", + "order": 3, + "is_closed": false + }, + { + "slug": "ready-for-test", + "color": "#E4CE40", + "name": "Ready for test", + "order": 4, + "is_closed": false + }, + { + "slug": "done", + "color": "#A8E440", + "name": "Done", + "order": 5, + "is_closed": true + } + ], + "us_statuses": [ + { + "slug": "new", + "name": "New", + "order": 1, + "is_archived": false, + "color": "#70728F", + "is_closed": false, + "wip_limit": null + }, + { + "slug": "ready", + "name": "Ready", + "order": 2, + "is_archived": false, + "color": "#E44057", + "is_closed": false, + "wip_limit": null + }, + { + "slug": "in-progress", + "name": "In progress", + "order": 3, + "is_archived": false, + "color": "#E47C40", + "is_closed": false, + "wip_limit": null + }, + { + "slug": "ready-for-test", + "name": "Ready for test", + "order": 4, + "is_archived": false, + "color": "#E4CE40", + "is_closed": false, + "wip_limit": null + }, + { + "slug": "done", + "name": "Done", + "order": 5, + "is_archived": false, + "color": "#A8E440", + "is_closed": true, + "wip_limit": null + }, + { + "slug": "archived", + "name": "Archived", + "order": 6, + "is_archived": true, + "color": "#A9AABC", + "is_closed": true, + "wip_limit": null + } + ], + "us_duedates": [ + { + "name": "Default", + "order": 1, + "by_default": true, + "color": "#9dce0a", + "days_to_due": null + }, + { + "name": "Due soon", + "order": 2, + "by_default": false, + "color": "#ff9900", + "days_to_due": 14 + }, + { + "name": "Past due", + "order": 3, + "by_default": false, + "color": "#E44057", + "days_to_due": 0 + } + ], + "points": [ + { + "name": "?", + "order": 1, + "value": null + }, + { + "name": "0", + "order": 2, + "value": 0.0 + }, + { + "name": "1/2", + "order": 3, + "value": 0.5 + }, + { + "name": "1", + "order": 4, + "value": 1.0 + }, + { + "name": "2", + "order": 5, + "value": 2.0 + }, + { + "name": "3", + "order": 6, + "value": 3.0 + }, + { + "name": "5", + "order": 7, + "value": 5.0 + }, + { + "name": "8", + "order": 8, + "value": 8.0 + }, + { + "name": "10", + "order": 9, + "value": 10.0 + }, + { + "name": "13", + "order": 10, + "value": 13.0 + }, + { + "name": "20", + "order": 11, + "value": 20.0 + }, + { + "name": "40", + "order": 12, + "value": 40.0 + } + ], + "task_statuses": [ + { + "slug": "new", + "color": "#70728F", + "name": "New", + "order": 1, + "is_closed": false + }, + { + "slug": "in-progress", + "color": "#E47C40", + "name": "In progress", + "order": 2, + "is_closed": false + }, + { + "slug": "ready-for-test", + "color": "#E4CE40", + "name": "Ready for test", + "order": 3, + "is_closed": false + }, + { + "slug": "closed", + "color": "#A8E440", + "name": "Closed", + "order": 4, + "is_closed": true + }, + { + "slug": "needs-info", + "color": "#5178D3", + "name": "Needs Info", + "order": 5, + "is_closed": false + } + ], + "task_duedates": [ + { + "name": "Default", + "order": 1, + "by_default": true, + "color": "#9dce0a", + "days_to_due": null + }, + { + "name": "Due soon", + "order": 2, + "by_default": false, + "color": "#ff9900", + "days_to_due": 14 + }, + { + "name": "Past due", + "order": 3, + "by_default": false, + "color": "#E44057", + "days_to_due": 0 + } + ], + "issue_statuses": [ + { + "slug": "new", + "color": "#70728F", + "name": "New", + "order": 1, + "is_closed": false + }, + { + "slug": "in-progress", + "color": "#40A8E4", + "name": "In progress", + "order": 2, + "is_closed": false + }, + { + "slug": "ready-for-test", + "color": "#E4CE40", + "name": "Ready for test", + "order": 3, + "is_closed": false + }, + { + "slug": "closed", + "color": "#A8E440", + "name": "Closed", + "order": 4, + "is_closed": true + }, + { + "slug": "needs-info", + "color": "#E44057", + "name": "Needs Info", + "order": 5, + "is_closed": false + }, + { + "slug": "rejected", + "color": "#A9AABC", + "name": "Rejected", + "order": 6, + "is_closed": true + }, + { + "slug": "posponed", + "color": "#5178D3", + "name": "Postponed", + "order": 7, + "is_closed": false + } + ], + "issue_types": [ + { + "color": "#E44057", + "name": "Bug", + "order": 1 + }, + { + "color": "#5178D3", + "name": "Question", + "order": 2 + }, + { + "color": "#40E4CE", + "name": "Enhancement", + "order": 3 + } + ], + "issue_duedates": [ + { + "name": "Default", + "order": 1, + "by_default": true, + "color": "#9dce0a", + "days_to_due": null + }, + { + "name": "Due soon", + "order": 2, + "by_default": false, + "color": "#ff9900", + "days_to_due": 14 + }, + { + "name": "Past due", + "order": 3, + "by_default": false, + "color": "#E44057", + "days_to_due": 0 + } + ], + "priorities": [ + { + "color": "#A8E440", + "name": "Low", + "order": 1 + }, + { + "color": "#E4CE40", + "name": "Normal", + "order": 3 + }, + { + "color": "#E47C40", + "name": "High", + "order": 5 + } + ], + "severities": [ + { + "color": "#70728F", + "name": "Wishlist", + "order": 1 + }, + { + "color": "#40A8E4", + "name": "Minor", + "order": 2 + }, + { + "color": "#40E47C", + "name": "Normal", + "order": 3 + }, + { + "color": "#E4A240", + "name": "Important", + "order": 4 + }, + { + "color": "#D35450", + "name": "Critical", + "order": 5 + } + ], + "roles": [ + { + "slug": "ux", + "computable": true, + "name": "UX", + "order": 10, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "design", + "computable": true, + "name": "Design", + "order": 20, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "front", + "computable": true, + "name": "Front", + "order": 30, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "back", + "computable": true, + "name": "Back", + "order": 40, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "product-owner", + "computable": false, + "name": "Product Owner", + "order": 50, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "stakeholder", + "computable": false, + "name": "Stakeholder", + "order": 60, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "view_milestones", + "view_project", + "view_tasks", + "view_us", + "modify_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + } + ], + "epic_custom_attributes": [], + "us_custom_attributes": [], + "task_custom_attributes": [], + "issue_custom_attributes": [] + } +}, +{ + "model": "projects.projecttemplate", + "pk": 2, + "fields": { + "name": "Kanban", + "slug": "kanban", + "description": "Kanban is a method for managing knowledge work with an emphasis on just-in-time delivery while not overloading the team members. In this approach, the process, from definition of a task to its delivery to the customer, is displayed for participants to see and team members pull work from a queue.", + "order": 2, + "created_date": "2014-04-22T14:50:19.738Z", + "modified_date": "2016-08-24T16:26:45.365Z", + "default_owner_role": "product-owner", + "is_epics_activated": false, + "is_backlog_activated": false, + "is_kanban_activated": true, + "is_wiki_activated": false, + "is_issues_activated": false, + "videoconferences": null, + "videoconferences_extra_data": "", + "default_options": { + "points": "?", + "priority": "Normal", + "us_status": "New", + "issue_type": "Bug", + "epic_status": "New", + "severity": "Normal", + "task_status": "New", + "issue_status": "New" + }, + "epic_statuses": [ + { + "slug": "new", + "color": "#70728F", + "name": "New", + "order": 1, + "is_closed": false + }, + { + "slug": "ready", + "color": "#E44057", + "name": "Ready", + "order": 2, + "is_closed": false + }, + { + "slug": "in-progress", + "color": "#E47C40", + "name": "In progress", + "order": 3, + "is_closed": false + }, + { + "slug": "ready-for-test", + "color": "#E4CE40", + "name": "Ready for test", + "order": 4, + "is_closed": false + }, + { + "slug": "done", + "color": "#A8E440", + "name": "Done", + "order": 5, + "is_closed": true + } + ], + "us_statuses": [ + { + "slug": "new", + "name": "New", + "order": 1, + "is_archived": false, + "color": "#70728F", + "is_closed": false, + "wip_limit": null + }, + { + "slug": "ready", + "name": "Ready", + "order": 2, + "is_archived": false, + "color": "#E44057", + "is_closed": false, + "wip_limit": null + }, + { + "slug": "in-progress", + "name": "In progress", + "order": 3, + "is_archived": false, + "color": "#E47C40", + "is_closed": false, + "wip_limit": null + }, + { + "slug": "ready-for-test", + "name": "Ready for test", + "order": 4, + "is_archived": false, + "color": "#E4CE40", + "is_closed": false, + "wip_limit": null + }, + { + "slug": "done", + "name": "Done", + "order": 5, + "is_archived": false, + "color": "#A8E440", + "is_closed": true, + "wip_limit": null + }, + { + "slug": "archived", + "name": "Archived", + "order": 6, + "is_archived": true, + "color": "#A9AABC", + "is_closed": true, + "wip_limit": null + } + ], + "us_duedates": [ + { + "name": "Default", + "order": 1, + "by_default": true, + "color": "#9dce0a", + "days_to_due": null + }, + { + "name": "Due soon", + "order": 2, + "by_default": false, + "color": "#ff9900", + "days_to_due": 14 + }, + { + "name": "Past due", + "order": 3, + "by_default": false, + "color": "#E44057", + "days_to_due": 0 + } + ], + "points": [ + { + "name": "?", + "order": 1, + "value": null + }, + { + "name": "0", + "order": 2, + "value": 0.0 + }, + { + "name": "1/2", + "order": 3, + "value": 0.5 + }, + { + "name": "1", + "order": 4, + "value": 1.0 + }, + { + "name": "2", + "order": 5, + "value": 2.0 + }, + { + "name": "3", + "order": 6, + "value": 3.0 + }, + { + "name": "5", + "order": 7, + "value": 5.0 + }, + { + "name": "8", + "order": 8, + "value": 8.0 + }, + { + "name": "10", + "order": 9, + "value": 10.0 + }, + { + "name": "13", + "order": 10, + "value": 13.0 + }, + { + "name": "20", + "order": 11, + "value": 20.0 + }, + { + "name": "40", + "order": 12, + "value": 40.0 + } + ], + "task_statuses": [ + { + "slug": "new", + "color": "#70728F", + "name": "New", + "order": 1, + "is_closed": false + }, + { + "slug": "in-progress", + "color": "#E47C40", + "name": "In progress", + "order": 2, + "is_closed": false + }, + { + "slug": "ready-for-test", + "color": "#E4CE40", + "name": "Ready for test", + "order": 3, + "is_closed": false + }, + { + "slug": "closed", + "color": "#A8E440", + "name": "Closed", + "order": 4, + "is_closed": true + }, + { + "slug": "needs-info", + "color": "#5178D3", + "name": "Needs Info", + "order": 5, + "is_closed": false + } + ], + "task_duedates": [ + { + "name": "Default", + "order": 1, + "by_default": true, + "color": "#9dce0a", + "days_to_due": null + }, + { + "name": "Due soon", + "order": 2, + "by_default": false, + "color": "#ff9900", + "days_to_due": 14 + }, + { + "name": "Past due", + "order": 3, + "by_default": false, + "color": "#E44057", + "days_to_due": 0 + } + ], + "issue_statuses": [ + { + "slug": "new", + "color": "#70728F", + "name": "New", + "order": 1, + "is_closed": false + }, + { + "slug": "in-progress", + "color": "#40A8E4", + "name": "In progress", + "order": 2, + "is_closed": false + }, + { + "slug": "ready-for-test", + "color": "#E47C40", + "name": "Ready for test", + "order": 3, + "is_closed": false + }, + { + "slug": "closed", + "color": "#A8E440", + "name": "Closed", + "order": 4, + "is_closed": true + }, + { + "slug": "needs-info", + "color": "#E44057", + "name": "Needs Info", + "order": 5, + "is_closed": false + }, + { + "slug": "rejected", + "color": "#A9AABC", + "name": "Rejected", + "order": 6, + "is_closed": true + }, + { + "slug": "posponed", + "color": "#5178D3", + "name": "Postponed", + "order": 7, + "is_closed": false + } + ], + "issue_types": [ + { + "color": "#E44057", + "name": "Bug", + "order": 1 + }, + { + "color": "#5178D3", + "name": "Question", + "order": 2 + }, + { + "color": "#40E4CE", + "name": "Enhancement", + "order": 3 + } + ], + "issue_duedates": [ + { + "name": "Default", + "order": 1, + "by_default": true, + "color": "#9dce0a", + "days_to_due": null + }, + { + "name": "Due soon", + "order": 2, + "by_default": false, + "color": "#ff9900", + "days_to_due": 14 + }, + { + "name": "Past due", + "order": 3, + "by_default": false, + "color": "#E44057", + "days_to_due": 0 + } + ], + "priorities": [ + { + "color": "#A9AABC", + "name": "Low", + "order": 1 + }, + { + "color": "#A8E440", + "name": "Normal", + "order": 3 + }, + { + "color": "#E44057", + "name": "High", + "order": 5 + } + ], + "severities": [ + { + "color": "#70728F", + "name": "Wishlist", + "order": 1 + }, + { + "color": "#40E47C", + "name": "Minor", + "order": 2 + }, + { + "color": "#A8E440", + "name": "Normal", + "order": 3 + }, + { + "color": "#E4CE40", + "name": "Important", + "order": 4 + }, + { + "color": "#E47C40", + "name": "Critical", + "order": 5 + } + ], + "roles": [ + { + "slug": "ux", + "computable": true, + "name": "UX", + "order": 10, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "design", + "computable": true, + "name": "Design", + "order": 20, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "front", + "computable": true, + "name": "Front", + "order": 30, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "back", + "computable": true, + "name": "Back", + "order": 40, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "product-owner", + "computable": false, + "name": "Product Owner", + "order": 50, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "add_milestone", + "modify_milestone", + "delete_milestone", + "view_milestones", + "view_project", + "add_task", + "modify_task", + "delete_task", + "view_tasks", + "add_us", + "modify_us", + "delete_us", + "view_us", + "add_wiki_page", + "modify_wiki_page", + "delete_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "add_epic", + "modify_epic", + "delete_epic", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + }, + { + "slug": "stakeholder", + "computable": false, + "name": "Stakeholder", + "order": 60, + "permissions": [ + "add_issue", + "modify_issue", + "delete_issue", + "view_issues", + "view_milestones", + "view_project", + "view_tasks", + "view_us", + "modify_wiki_page", + "view_wiki_pages", + "add_wiki_link", + "delete_wiki_link", + "view_wiki_links", + "view_epics", + "comment_epic", + "comment_us", + "comment_task", + "comment_issue", + "comment_wiki_page" + ] + } + ], + "epic_custom_attributes": [], + "us_custom_attributes": [], + "task_custom_attributes": [], + "issue_custom_attributes": [] + } +} +] diff --git a/taiga/projects/history/__init__.py b/taiga/projects/history/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/history/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/history/api.py b/taiga/projects/history/api.py new file mode 100644 index 000000000..c111adaec --- /dev/null +++ b/taiga/projects/history/api.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ +from django.utils import timezone + +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 +from . import services + + +class HistoryViewSet(ReadOnlyListViewSet): + serializer_class = serializers.HistoryEntrySerializer + + content_type = None + + def get_content_type(self): + app_name, model = self.content_type.split(".", 1) + return ContentType.objects.get_by_natural_key(app_name, model) + + def get_queryset(self): + ct = self.get_content_type() + model_cls = ct.model_class() + + qs = model_cls.objects.all() + filtered_qs = self.filter_queryset(qs) + return filtered_qs + + def response_for_queryset(self, queryset): + # Switch between paginated or standard style responses + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_pagination_serializer(page) + else: + serializer = self.get_serializer(queryset, many=True) + + 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() + history_entry_id = request.QUERY_PARAMS.get('id', None) + history_entry = services.get_history_queryset_by_model_instance(obj).filter(id=history_entry_id).first() + if history_entry is None: + return response.NotFound() + + self.check_permissions(request, 'comment_versions', history_entry) + + if history_entry is None: + return response.NotFound() + + history_entry.attach_user_info_to_comment_versions() + return response.Ok(history_entry.comment_versions) + + @detail_route(methods=['post']) + def edit_comment(self, request, pk): + obj = self.get_object() + history_entry_id = request.QUERY_PARAMS.get('id', None) + history_entry = services.get_history_queryset_by_model_instance(obj).filter(id=history_entry_id).first() + if history_entry is None: + return response.NotFound() + + obj = services.get_instance_from_key(history_entry.key) + comment = request.DATA.get("comment", None) + + self.check_permissions(request, 'edit_comment', history_entry) + + if history_entry is None: + return response.NotFound() + + if comment is None: + return response.BadRequest({"error": _("comment is required")}) + + if history_entry.delete_comment_date or history_entry.delete_comment_user: + return response.BadRequest({"error": _("deleted comments can't be edited")}) + + # comment_versions can be None if there are no historic versions of the comment + comment_versions = history_entry.comment_versions or [] + comment_versions.append({ + "date": history_entry.created_at, + "comment": history_entry.comment, + "comment_html": history_entry.comment_html, + "user": { + "id": request.user.pk, + } + }) + + 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']) + def delete_comment(self, request, pk): + obj = self.get_object() + history_entry_id = request.QUERY_PARAMS.get('id', None) + history_entry = services.get_history_queryset_by_model_instance(obj).filter(id=history_entry_id).first() + if history_entry is None: + return response.NotFound() + + self.check_permissions(request, 'delete_comment', history_entry) + + if history_entry is None: + return response.NotFound() + + if history_entry.delete_comment_date or history_entry.delete_comment_user: + return response.BadRequest({"error": _("Comment already deleted")}) + + history_entry.delete_comment_date = timezone.now() + history_entry.delete_comment_user = {"pk": request.user.pk, "name": request.user.get_full_name()} + history_entry.save() + return response.Ok() + + @detail_route(methods=['post']) + def undelete_comment(self, request, pk): + obj = self.get_object() + history_entry_id = request.QUERY_PARAMS.get('id', None) + history_entry = services.get_history_queryset_by_model_instance(obj).filter(id=history_entry_id).first() + if history_entry is None: + return response.NotFound() + + self.check_permissions(request, 'undelete_comment', history_entry) + + if history_entry is None: + return response.NotFound() + + if not history_entry.delete_comment_date and not history_entry.delete_comment_user: + return response.BadRequest({"error": _("Comment not deleted")}) + + history_entry.delete_comment_date = None + history_entry.delete_comment_user = None + history_entry.save() + return response.Ok() + + # Just for restframework! Because it raises + # 404 on main api root if this method not exists. + def list(self, request): + return response.NotFound() + + def retrieve(self, request, pk): + obj = self.get_object() + self.check_permissions(request, "retrieve", obj) + qs = services.get_history_queryset_by_model_instance(obj) + + history_type = self.request.GET.get('type') + if history_type == 'activity': + qs = qs.filter(diff__isnull=False, comment__exact='').exclude(diff__exact='') + + if history_type == 'comment': + qs = qs.exclude(comment__exact='') + + qs = qs.order_by("-created_at") + qs = services.prefetch_owners_in_history_queryset(qs) + + if self.request.GET.get(self.page_kwarg): + page = self.paginate_queryset(qs) + serializer = self.get_pagination_serializer(page) + return response.Ok(serializer.data) + + return self.response_for_queryset(qs) + + +class EpicHistory(HistoryViewSet): + content_type = "epics.epic" + permission_classes = (permissions.EpicHistoryPermission,) + + +class UserStoryHistory(HistoryViewSet): + content_type = "userstories.userstory" + permission_classes = (permissions.UserStoryHistoryPermission,) + + +class TaskHistory(HistoryViewSet): + content_type = "tasks.task" + permission_classes = (permissions.TaskHistoryPermission,) + + +class IssueHistory(HistoryViewSet): + content_type = "issues.issue" + permission_classes = (permissions.IssueHistoryPermission,) + + +class WikiHistory(HistoryViewSet): + content_type = "wiki.wikipage" + permission_classes = (permissions.WikiHistoryPermission,) diff --git a/taiga/projects/history/choices.py b/taiga/projects/history/choices.py new file mode 100644 index 000000000..9eec9b25f --- /dev/null +++ b/taiga/projects/history/choices.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import enum + +from django.utils.translation import gettext_lazy as _ + + +class HistoryType(enum.IntEnum): + change = 1 + create = 2 + delete = 3 + + +HISTORY_TYPE_CHOICES = ((HistoryType.change, _("Change")), + (HistoryType.create, _("Create")), + (HistoryType.delete, _("Delete"))) diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py new file mode 100644 index 000000000..8705d28a5 --- /dev/null +++ b/taiga/projects/history/freeze_impl.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from contextlib import suppress + +from functools import partial +from django.apps import apps +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist + +from taiga.base.utils.iterators import as_tuple +from taiga.base.utils.iterators import as_dict +from taiga.mdrender.service import render as mdrender + +from taiga.projects.attachments.services import get_timeline_image_thumbnail_name + +import os + +#################### +# Values +#################### + + +@as_dict +def _get_generic_values(ids: tuple, *, typename=None, attr: str="name") -> tuple: + app_label, model_name = typename.split(".", 1) + content_type = ContentType.objects.get(app_label=app_label, model=model_name) + model_cls = content_type.model_class() + + ids = filter(lambda x: x is not None, ids) + qs = model_cls.objects.filter(pk__in=ids) + for instance in qs: + yield str(instance.pk), getattr(instance, attr) + + +@as_dict +def _get_users_values(ids: set) -> dict: + user_model = get_user_model() + ids = filter(lambda x: x is not None, ids) + qs = user_model.objects.filter(pk__in=tuple(ids)) + + for user in qs: + yield str(user.pk), user.get_full_name() + + +@as_dict +def _get_user_story_values(ids: set) -> dict: + userstory_model = apps.get_model("userstories", "UserStory") + ids = filter(lambda x: x is not None, ids) + qs = userstory_model.objects.filter(pk__in=tuple(ids)) + + for userstory in qs: + yield str(userstory.pk), "#{} {}".format(userstory.ref, userstory.subject) + + +_get_us_status_values = partial(_get_generic_values, typename="projects.userstorystatus") +_get_swimlane_values = partial(_get_generic_values, typename="projects.swimlane") +_get_task_status_values = partial(_get_generic_values, typename="projects.taskstatus") +_get_epic_status_values = partial(_get_generic_values, typename="projects.epicstatus") +_get_issue_status_values = partial(_get_generic_values, typename="projects.issuestatus") +_get_issue_type_values = partial(_get_generic_values, typename="projects.issuetype") +_get_role_values = partial(_get_generic_values, typename="users.role") +_get_points_values = partial(_get_generic_values, typename="projects.points") +_get_priority_values = partial(_get_generic_values, typename="projects.priority") +_get_severity_values = partial(_get_generic_values, typename="projects.severity") +_get_milestone_values = partial(_get_generic_values, typename="milestones.milestone") + + +def _common_users_values(diff): + """ + Groups common values resolver logic of userstories, + issues and tasks. + """ + values = {} + users = set() + + if "owner" in diff and isinstance(diff["owner"], int): + users.update(diff["owner"]) + if "assigned_to" in diff: + users.update(diff["assigned_to"]) + if "assigned_users" in diff: + [users.update(usrs_ids) for usrs_ids in diff["assigned_users"] if + usrs_ids] + + user_ids = [user_id for user_id in users if isinstance(user_id, int)] + values["users"] = _get_users_values(set(user_ids)) if users else {} + + return values + + +def project_values(diff): + values = _common_users_values(diff) + return values + + +def milestone_values(diff): + values = _common_users_values(diff) + return values + + +def epic_values(diff): + values = _common_users_values(diff) + + if "status" in diff: + values["status"] = _get_epic_status_values(diff["status"]) + + return values + + +def epic_related_userstory_values(diff): + values = _common_users_values(diff) + return values + + +def userstory_values(diff): + values = _common_users_values(diff) + + if "status" in diff: + values["status"] = _get_us_status_values(diff["status"]) + if "swimlane" in diff: + values["swimlane"] = _get_swimlane_values(diff["swimlane"]) + if "milestone" in diff: + values["milestone"] = _get_milestone_values(diff["milestone"]) + if "points" in diff: + points, roles = set(), set() + + for pointsentry in diff["points"]: + if pointsentry is None: + continue + + for role_id, point_id in pointsentry.items(): + points.add(point_id) + roles.add(role_id) + + values["roles"] = _get_role_values(roles) + values["points"] = _get_points_values(points) + + return values + + +def issue_values(diff): + values = _common_users_values(diff) + + if "status" in diff: + values["status"] = _get_issue_status_values(diff["status"]) + if "milestone" in diff: + values["milestone"] = _get_milestone_values(diff["milestone"]) + if "priority" in diff: + values["priority"] = _get_priority_values(diff["priority"]) + if "severity" in diff: + values["severity"] = _get_severity_values(diff["severity"]) + if "type" in diff: + values["type"] = _get_issue_type_values(diff["type"]) + + return values + + +def task_values(diff): + values = _common_users_values(diff) + + if "status" in diff: + values["status"] = _get_task_status_values(diff["status"]) + if "milestone" in diff: + values["milestone"] = _get_milestone_values(diff["milestone"]) + if "user_story" in diff: + values["user_story"] = _get_user_story_values(diff["user_story"]) + + return values + + +def wikipage_values(diff): + values = _common_users_values(diff) + return values + + +#################### +# Freezes +#################### + +def _generic_extract(obj: object, fields: list, default=None) -> dict: + result = {} + for fieldname in fields: + result[fieldname] = getattr(obj, fieldname, default) + return result + + +@as_tuple +def extract_attachments(obj) -> list: + for attach in obj.attachments.all(): + # Force the creation of a thumbnail for the timeline + thumbnail_file = get_timeline_image_thumbnail_name(attach) + + yield {"id": attach.id, + "filename": os.path.basename(attach.attached_file.name), + "url": attach.attached_file.url, + "attached_file": str(attach.attached_file), + "thumbnail_file": thumbnail_file, + "is_deprecated": attach.is_deprecated, + "description": attach.description, + "order": attach.order} + + +@as_tuple +def extract_epic_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.epiccustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value, + "type": attr.type} + + +@as_tuple +def extract_user_story_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.userstorycustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value, + "type": attr.type} + + +@as_tuple +def extract_task_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.taskcustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value, + "type": attr.type} + + +@as_tuple +def extract_issue_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.issuecustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value, + "type": attr.type} + + +def project_freezer(project) -> dict: + fields = ("name", + "slug", + "created_at", + "owner_id", + "is_private", + "anon_permissions", + "public_permissions", + "total_milestones", + "total_story_points", + "tags", + "is_epics_activated", + "is_backlog_activated", + "is_kanban_activated", + "is_wiki_activated", + "is_issues_activated") + return _generic_extract(project, fields) + + +def milestone_freezer(milestone) -> dict: + snapshot = { + "name": milestone.name, + "slug": milestone.slug, + "owner": milestone.owner_id, + "estimated_start": milestone.estimated_start, + "estimated_finish": milestone.estimated_finish, + "closed": milestone.closed, + "disponibility": milestone.disponibility + } + + return snapshot + + +def epic_freezer(epic) -> dict: + snapshot = { + "ref": epic.ref, + "color": epic.color, + "owner": epic.owner_id, + "status": epic.status.id if epic.status else None, + "epics_order": epic.epics_order, + "subject": epic.subject, + "description": epic.description, + "description_html": mdrender(epic.project, epic.description), + "assigned_to": epic.assigned_to_id, + "client_requirement": epic.client_requirement, + "team_requirement": epic.team_requirement, + "attachments": extract_attachments(epic), + "tags": epic.tags, + "is_blocked": epic.is_blocked, + "blocked_note": epic.blocked_note, + "blocked_note_html": mdrender(epic.project, epic.blocked_note), + "custom_attributes": extract_epic_custom_attributes(epic) + } + + return snapshot + + +def epic_related_userstory_freezer(related_us) -> dict: + snapshot = { + "user_story": related_us.user_story.id, + "epic": related_us.epic.id, + "order": related_us.order + } + + return snapshot + + +def userstory_freezer(us) -> dict: + rp_cls = apps.get_model("userstories", "RolePoints") + rpqsd = rp_cls.objects.filter(user_story=us) + + points = {} + for rp in rpqsd: + points[str(rp.role_id)] = rp.points_id + + assigned_users = [u.id for u in us.assigned_users.all()] + # Due to multiple assignment migration, for new snapshots we add to + # assigned users a list with the 'assigned to' value + if us.assigned_to_id and not assigned_users: + assigned_users = [us.assigned_to_id] + + snapshot = { + "ref": us.ref, + "owner": us.owner_id, + "status": us.status.id if us.status else None, + "swimlane": us.swimlane.id if us.swimlane else None, + "is_closed": us.is_closed, + "finish_date": str(us.finish_date), + "backlog_order": us.backlog_order, + "sprint_order": us.sprint_order, + "kanban_order": us.kanban_order, + "subject": us.subject, + "description": us.description, + "description_html": mdrender(us.project, us.description), + "assigned_to": us.assigned_to_id, + "assigned_users": assigned_users, + "milestone": us.milestone_id, + "client_requirement": us.client_requirement, + "team_requirement": us.team_requirement, + "attachments": extract_attachments(us), + "tags": us.tags, + "points": points, + "from_issue": us.generated_from_issue_id, + "from_task": us.generated_from_task_id, + "is_blocked": us.is_blocked, + "blocked_note": us.blocked_note, + "blocked_note_html": mdrender(us.project, us.blocked_note), + "custom_attributes": extract_user_story_custom_attributes(us), + "tribe_gig": us.tribe_gig, + "due_date": str(us.due_date) if us.due_date else None + } + + return snapshot + + +def issue_freezer(issue) -> dict: + promoted_to = list(issue.generated_user_stories.values_list("id", flat=True)) + + snapshot = { + "ref": issue.ref, + "owner": issue.owner_id, + "status": issue.status.id if issue.status else None, + "priority": issue.priority_id, + "severity": issue.severity_id, + "type": issue.type_id, + "milestone": issue.milestone_id, + "subject": issue.subject, + "description": issue.description, + "description_html": mdrender(issue.project, issue.description), + "assigned_to": issue.assigned_to_id, + "attachments": extract_attachments(issue), + "tags": issue.tags, + "is_blocked": issue.is_blocked, + "blocked_note": issue.blocked_note, + "blocked_note_html": mdrender(issue.project, issue.blocked_note), + "custom_attributes": extract_issue_custom_attributes(issue), + "due_date": str(issue.due_date) if issue.due_date else None, + "promoted_to": promoted_to, + } + + return snapshot + + +def task_freezer(task) -> dict: + promoted_to = list(task.generated_user_stories.values_list("id", flat=True)) + + snapshot = { + "ref": task.ref, + "owner": task.owner_id, + "status": task.status.id if task.status else None, + "milestone": task.milestone_id, + "subject": task.subject, + "description": task.description, + "description_html": mdrender(task.project, task.description), + "assigned_to": task.assigned_to_id, + "attachments": extract_attachments(task), + "taskboard_order": task.taskboard_order, + "us_order": task.us_order, + "tags": task.tags, + "user_story": task.user_story_id, + "is_iocaine": task.is_iocaine, + "is_blocked": task.is_blocked, + "blocked_note": task.blocked_note, + "blocked_note_html": mdrender(task.project, task.blocked_note), + "custom_attributes": extract_task_custom_attributes(task), + "due_date": str(task.due_date) if task.due_date else None, + "promoted_to": promoted_to, + } + + return snapshot + + +def wikipage_freezer(wiki) -> dict: + snapshot = { + "slug": wiki.slug, + "owner": wiki.owner_id, + "content": wiki.content, + "content_html": mdrender(wiki.project, wiki.content), + "attachments": extract_attachments(wiki), + } + + return snapshot diff --git a/taiga/projects/history/migrations/0001_initial.py b/taiga/projects/history/migrations/0001_initial.py new file mode 100644 index 000000000..4295d4b18 --- /dev/null +++ b/taiga/projects/history/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.projects.history.models +import django.utils.timezone +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='HistoryEntry', + fields=[ + ('id', models.CharField(primary_key=True, unique=True, max_length=255, serialize=False, default=taiga.projects.history.models._generate_uuid, editable=False)), + ('user', taiga.base.db.models.fields.JSONField(default=None, blank=True, null=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('type', models.SmallIntegerField(choices=[(1, 'Change'), (2, 'Create'), (3, 'Delete')])), + ('is_snapshot', models.BooleanField(default=False)), + ('key', models.CharField(max_length=255, default=None, blank=True, null=True)), + ('diff', taiga.base.db.models.fields.JSONField(default=None, null=True)), + ('snapshot', taiga.base.db.models.fields.JSONField(default=None, null=True)), + ('values', taiga.base.db.models.fields.JSONField(default=None, null=True)), + ('comment', models.TextField(blank=True)), + ('comment_html', models.TextField(blank=True)), + ], + options={ + 'ordering': ['created_at'], + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/projects/history/migrations/0002_auto_20140916_0936.py b/taiga/projects/history/migrations/0002_auto_20140916_0936.py new file mode 100644 index 000000000..79f56dfa4 --- /dev/null +++ b/taiga/projects/history/migrations/0002_auto_20140916_0936.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('history', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='historyentry', + name='delete_comment_date', + field=models.DateTimeField(default=None, null=True, blank=True), + preserve_default=True, + ), + migrations.AddField( + model_name='historyentry', + name='delete_comment_user', + field=models.ForeignKey(null=True, default=None, related_name='deleted_comments', to=settings.AUTH_USER_MODEL, blank=True, on_delete=models.CASCADE), + preserve_default=True, + ), + ] diff --git a/taiga/projects/history/migrations/0003_auto_20140917_1405.py b/taiga/projects/history/migrations/0003_auto_20140917_1405.py new file mode 100644 index 000000000..6bd1e3416 --- /dev/null +++ b/taiga/projects/history/migrations/0003_auto_20140917_1405.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import taiga.base.db.models.fields + +def change_fk_with_tuple_pk_and_name(apps, schema_editor): + HistoryEntry = apps.get_model("history", "HistoryEntry") + + for item in HistoryEntry.objects.all(): + if item.delete_comment_user_old: + item.delete_comment_user = {"pk": item.delete_comment_user_old.pk, "name": item.delete_comment_user_old.name} + else: + item.delete_comment_user = None + item.save() + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('history', '0002_auto_20140916_0936'), + ] + + operations = [ + migrations.RenameField( + model_name='historyentry', + old_name='delete_comment_user', + new_name='delete_comment_user_old', + ), + migrations.AddField( + model_name='historyentry', + name='delete_comment_user', + field=taiga.base.db.models.fields.JSONField(null=True, blank=True, default=None), + preserve_default=True, + ), + + migrations.RunPython(change_fk_with_tuple_pk_and_name), + + migrations.RemoveField( + model_name='historyentry', + name='delete_comment_user_old', + ), + ] diff --git a/taiga/projects/history/migrations/0004_historyentry_is_hidden.py b/taiga/projects/history/migrations/0004_historyentry_is_hidden.py new file mode 100644 index 000000000..2b6450510 --- /dev/null +++ b/taiga/projects/history/migrations/0004_historyentry_is_hidden.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0003_auto_20140917_1405'), + ] + + operations = [ + migrations.AddField( + model_name='historyentry', + name='is_hidden', + field=models.BooleanField(default=False), + preserve_default=True, + ), + ] diff --git a/taiga/projects/history/migrations/0005_auto_20141120_1119.py b/taiga/projects/history/migrations/0005_auto_20141120_1119.py new file mode 100644 index 000000000..f7b44e8bc --- /dev/null +++ b/taiga/projects/history/migrations/0005_auto_20141120_1119.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0004_historyentry_is_hidden'), + ] + + operations = [ + migrations.AlterField( + model_name='historyentry', + name='key', + field=models.CharField(default=None, blank=True, max_length=255, db_index=True, null=True), + ), + ] diff --git a/taiga/projects/history/migrations/0006_fix_json_field_not_null.py b/taiga/projects/history/migrations/0006_fix_json_field_not_null.py new file mode 100644 index 000000000..2f66ce116 --- /dev/null +++ b/taiga/projects/history/migrations/0006_fix_json_field_not_null.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from taiga.base.db.models.fields import JSONField + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0005_auto_20141120_1119'), + ] + + operations = [ + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "user" DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "diff" DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "snapshot" DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "values" DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "delete_comment_user" DROP NOT NULL;', + ), + ] diff --git a/taiga/projects/history/migrations/0007_set_bloked_note_and_is_blocked_in_snapshots.py b/taiga/projects/history/migrations/0007_set_bloked_note_and_is_blocked_in_snapshots.py new file mode 100644 index 000000000..41538ffeb --- /dev/null +++ b/taiga/projects/history/migrations/0007_set_bloked_note_and_is_blocked_in_snapshots.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.core.exceptions import ObjectDoesNotExist +from taiga.projects.history.services import (make_key_from_model_object, + get_model_from_key, + get_pk_from_key) + + +def set_current_values_of_blocked_note_and_is_blocked_to_the_last_snapshot(apps, schema_editor): + HistoryEntry = apps.get_model("history", "HistoryEntry") + + for history_entry in HistoryEntry.objects.filter(is_snapshot=True).order_by("created_at"): + model = get_model_from_key(history_entry.key) + pk = get_pk_from_key(history_entry.key) + try: + obj = model.objects.get(pk=pk) + save = False + if hasattr(obj, "is_blocked") and "is_blocked" not in history_entry.snapshot: + history_entry.snapshot["is_blocked"] = obj.is_blocked + save = True + + if hasattr(obj, "blocked_note") and "blocked_note" not in history_entry.snapshot: + history_entry.snapshot["blocked_note"] = obj.blocked_note + save = True + + if save: + history_entry.save() + + except ObjectDoesNotExist as e: + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0006_fix_json_field_not_null'), + ('userstories', '0009_remove_userstory_is_archived'), + ('tasks', '0005_auto_20150114_0954'), + ('issues', '0004_auto_20150114_0954'), + ] + + operations = [ + migrations.RunPython(set_current_values_of_blocked_note_and_is_blocked_to_the_last_snapshot), + ] diff --git a/taiga/projects/history/migrations/0008_auto_20150508_1028.py b/taiga/projects/history/migrations/0008_auto_20150508_1028.py new file mode 100644 index 000000000..5d35a6bf9 --- /dev/null +++ b/taiga/projects/history/migrations/0008_auto_20150508_1028.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0007_set_bloked_note_and_is_blocked_in_snapshots'), + ] + + operations = [ + migrations.AlterField( + model_name='historyentry', + name='diff', + field=taiga.base.db.models.fields.JSONField(null=True, default=None, blank=True), + preserve_default=True, + ), + migrations.AlterField( + model_name='historyentry', + name='snapshot', + field=taiga.base.db.models.fields.JSONField(null=True, default=None, blank=True), + preserve_default=True, + ), + migrations.AlterField( + model_name='historyentry', + name='values', + field=taiga.base.db.models.fields.JSONField(null=True, default=None, blank=True), + preserve_default=True, + ), + ] diff --git a/taiga/projects/history/migrations/0009_auto_20160512_1110.py b/taiga/projects/history/migrations/0009_auto_20160512_1110.py new file mode 100644 index 000000000..249e9a664 --- /dev/null +++ b/taiga/projects/history/migrations/0009_auto_20160512_1110.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-05-12 11:10 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0008_auto_20150508_1028'), + ] + + operations = [ + migrations.AddField( + model_name='historyentry', + name='comment_versions', + field=taiga.base.db.models.fields.JSONField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='historyentry', + name='edit_comment_date', + field=models.DateTimeField(blank=True, default=None, null=True), + ), + ] diff --git a/taiga/projects/history/migrations/0010_historyentry_project.py b/taiga/projects/history/migrations/0010_historyentry_project.py new file mode 100644 index 000000000..aa7aacf47 --- /dev/null +++ b/taiga/projects/history/migrations/0010_historyentry_project.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-24 12:19 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0048_auto_20160615_1508'), + ('history', '0009_auto_20160512_1110'), + ] + + operations = [ + migrations.AddField( + model_name='historyentry', + name='project', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='projects.Project'), + ), + ] diff --git a/taiga/projects/history/migrations/0011_auto_20160629_1036.py b/taiga/projects/history/migrations/0011_auto_20160629_1036.py new file mode 100644 index 000000000..d174fee21 --- /dev/null +++ b/taiga/projects/history/migrations/0011_auto_20160629_1036.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-29 10:36 +from __future__ import unicode_literals + +from django.db import migrations, connection +from taiga.projects.history.services import get_instance_from_key + + +GENERATE_CORRECT_HISTORY_ENTRIES_TABLE = """ + -- Creating a table containing all the existing object keys and the project ids + DROP TABLE IF EXISTS project_keys; + CREATE TABLE project_keys ( + key VARCHAR, + project_id INTEGER + ); + + DROP INDEX IF EXISTS project_keys_index; + CREATE INDEX project_keys_index + ON project_keys + USING btree + (key); + + INSERT INTO project_keys + SELECT 'milestones.milestone:' || id, project_id + FROM milestones_milestone; + + INSERT INTO project_keys + SELECT 'userstories.userstory:' || id, project_id + FROM userstories_userstory; + + INSERT INTO project_keys + SELECT 'tasks.task:' || id, project_id + FROM tasks_task; + + INSERT INTO project_keys + SELECT 'issues.issue:' || id, project_id + FROM issues_issue; + + INSERT INTO project_keys + SELECT 'wiki.wikipage:' || id, project_id + FROM wiki_wikipage; + + INSERT INTO project_keys + SELECT 'projects.project:' || id, id + FROM projects_project; + + -- Create a table where we will insert all the history_historyentry content with its correct project_id + -- Elements without project_id won't be inserted + DROP TABLE IF EXISTS history_historyentry_correct; + CREATE TABLE history_historyentry_correct AS + SELECT + history_historyentry.id , + history_historyentry.user, + history_historyentry.created_at, + history_historyentry.type, + history_historyentry.is_snapshot, + history_historyentry.key, + history_historyentry.diff, + history_historyentry.snapshot, + history_historyentry.values, + history_historyentry.comment, + history_historyentry.comment_html, + history_historyentry.delete_comment_date, + history_historyentry.delete_comment_user, + history_historyentry.is_hidden, + history_historyentry.comment_versions, + history_historyentry.edit_comment_date, + project_keys.project_id + FROM history_historyentry + INNER JOIN project_keys + ON project_keys.key = history_historyentry.key; + + -- Delete aux table + DROP TABLE IF EXISTS project_keys; + """ + +def get_constraints_def_sql(table_name): + cursor = connection.cursor() + query = """ + SELECT 'ALTER TABLE "'||nspname||'"."'||relname||'" ADD CONSTRAINT "'||conname||'" '|| + pg_get_constraintdef(pg_constraint.oid)||';' + FROM pg_constraint + INNER JOIN pg_class ON conrelid=pg_class.oid + INNER JOIN pg_namespace ON pg_namespace.oid=pg_class.relnamespace + WHERE relname='{}' + ORDER BY CASE WHEN contype='f' THEN 0 ELSE 1 END DESC,contype DESC,nspname DESC,relname DESC,conname DESC; + """.format(table_name) + cursor.execute(query) + return [row[0] for row in cursor.fetchall()] + + +def get_indexes_def_sql(table_name): + cursor = connection.cursor() + query = """ + SELECT pg_get_indexdef(idx.oid)||';' + FROM pg_index ind + JOIN pg_class idx ON idx.oid = ind.indexrelid + JOIN pg_class tbl ON tbl.oid = ind.indrelid + LEFT JOIN pg_namespace ns ON ns.oid = tbl.relnamespace + WHERE + tbl.relname = '{}' AND + indisprimary=FALSE; + """.format(table_name) + cursor.execute(query) + return [row[0] for row in cursor.fetchall()] + + +def drop_constraints(table_name): + # This query returns all the ALTER sentences needed to drop the constraints + cursor = connection.cursor() + alter_sentences_query = """ + SELECT 'ALTER TABLE "'||nspname||'"."'||relname||'" DROP CONSTRAINT "'||conname||'" '||';' + FROM pg_constraint + INNER JOIN pg_class ON conrelid=pg_class.oid + INNER JOIN pg_namespace ON pg_namespace.oid=pg_class.relnamespace + WHERE relname='{}' + ORDER BY CASE WHEN contype='f' THEN 0 ELSE 1 END DESC,contype DESC,nspname DESC,relname DESC,conname DESC; + """.format(table_name) + cursor.execute(alter_sentences_query) + alter_sentences = [row[0] for row in cursor.fetchall()] + + #Now we execute those sentences + for alter_sentence in alter_sentences: + cursor.execute(alter_sentence) + + +def toggle_history_entries_tables(apps, schema_editor): + history_entry_sql_def_contraints = get_constraints_def_sql("history_historyentry") + history_entry_sql_def_indexes = get_indexes_def_sql("history_historyentry") + history_change_notifications_sql_def_contraints = get_constraints_def_sql("notifications_historychangenotification_history_entries") + drop_constraints("notifications_historychangenotification_history_entries") + cursor = connection.cursor() + cursor.execute(""" + DELETE FROM notifications_historychangenotification_history_entries; + DROP TABLE history_historyentry; + ALTER TABLE "history_historyentry_correct" RENAME to "history_historyentry"; + """) + + for history_entry_sql_def_contraint in history_entry_sql_def_contraints: + cursor.execute(history_entry_sql_def_contraint) + + for history_entry_sql_def_index in history_entry_sql_def_indexes: + cursor.execute(history_entry_sql_def_index) + + # Restoring the dropped constraints and indexes + for history_change_notifications_sql_def_contraint in history_change_notifications_sql_def_contraints: + cursor.execute(history_change_notifications_sql_def_contraint) + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0010_historyentry_project'), + ('wiki', '0003_auto_20160615_0721'), + ('users', '0022_auto_20160629_1443') + ] + + operations = [ + migrations.RunSQL(GENERATE_CORRECT_HISTORY_ENTRIES_TABLE), + migrations.RunPython(toggle_history_entries_tables) + ] diff --git a/taiga/projects/history/migrations/0012_auto_20160629_1036.py b/taiga/projects/history/migrations/0012_auto_20160629_1036.py new file mode 100644 index 000000000..8c97b8125 --- /dev/null +++ b/taiga/projects/history/migrations/0012_auto_20160629_1036.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-29 10:36 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0011_auto_20160629_1036'), + ] + + operations = [ + migrations.AlterField( + model_name='historyentry', + name='project', + field=models.ForeignKey(on_delete=models.deletion.CASCADE, to='projects.Project'), + ), + ] diff --git a/taiga/projects/history/migrations/0013_historyentry_values_diff_cache.py b/taiga/projects/history/migrations/0013_historyentry_values_diff_cache.py new file mode 100644 index 000000000..51b149334 --- /dev/null +++ b/taiga/projects/history/migrations/0013_historyentry_values_diff_cache.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-10-11 12:17 +from __future__ import unicode_literals + +from django.db import migrations +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0012_auto_20160629_1036'), + ] + + operations = [ + migrations.AddField( + model_name='historyentry', + name='values_diff_cache', + field=taiga.base.db.models.fields.JSONField(blank=True, default=None, null=True), + ), + ] diff --git a/taiga/projects/history/migrations/0014_json_to_jsonb.py b/taiga/projects/history/migrations/0014_json_to_jsonb.py new file mode 100644 index 000000000..dc823373c --- /dev/null +++ b/taiga/projects/history/migrations/0014_json_to_jsonb.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.2 on 2016-10-26 11:34 +from __future__ import unicode_literals + +from django.db import migrations +from django.contrib.postgres.fields import JSONField + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0013_historyentry_values_diff_cache'), + ] + + operations = [ + migrations.RunSQL( + """ + ALTER TABLE "history_historyentry" + ALTER COLUMN "delete_comment_user" + TYPE jsonb + USING regexp_replace("delete_comment_user"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "comment_versions" + TYPE jsonb + USING regexp_replace("comment_versions"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "values_diff_cache" + TYPE jsonb + USING regexp_replace("values_diff_cache"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "user" + TYPE jsonb + USING regexp_replace("user"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "diff" + TYPE jsonb + USING regexp_replace("diff"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "snapshot" + TYPE jsonb + USING regexp_replace("snapshot"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "values" + TYPE jsonb + USING regexp_replace("values"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """, + reverse_sql=migrations.RunSQL.noop + ), + ] diff --git a/taiga/projects/history/migrations/__init__.py b/taiga/projects/history/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/history/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/history/mixins.py b/taiga/projects/history/mixins.py new file mode 100644 index 000000000..d0f7f78f8 --- /dev/null +++ b/taiga/projects/history/mixins.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import warnings + +from .services import take_snapshot +from taiga.projects.notifications import services as notifications_services +from taiga.base.api import serializers +from taiga.base.fields import MethodField + + +class HistoryResourceMixin(object): + """ + Rest Framework resource mixin for resources + susceptible to have models with history. + """ + + # This attribute will store the last history entry + # created for this resource. It is mainly used for + # notifications mixin. + __last_history = None + __object_saved = False + + def get_last_history(self): + if not self.__object_saved: + message = ("get_last_history() function called before any object are saved. " + "Seems you have a wrong mixing order on your resource.") + warnings.warn(message, RuntimeWarning) + return self.__last_history + + def get_object_for_snapshot(self, obj): + """ + Method that returns a model instance ready to snapshot. + It is by default noop, but should be overwrited when + snapshot ready instance is found in one of foreign key + fields. + """ + return obj + + def persist_history_snapshot(self, obj=None, delete:bool=False): + """ + Shortcut for resources with special save/persist + logic. + """ + + user = self.request.user + comment = "" + if isinstance(self.request.DATA, dict): + comment = self.request.DATA.get("comment", "") + + if obj is None: + obj = self.get_object() + + sobj = self.get_object_for_snapshot(obj) + if sobj != obj: + delete = False + + notifications_services.analize_object_for_watchers(obj, comment, user) + + self.__last_history = take_snapshot(sobj, comment=comment, user=user, delete=delete) + self.__object_saved = True + + def post_save(self, obj, created=False): + self.persist_history_snapshot(obj=obj) + super().post_save(obj, created=created) + + def pre_delete(self, obj): + self.persist_history_snapshot(obj, delete=True) + super().pre_delete(obj) + + +class TotalCommentsSerializerMixin(serializers.LightSerializer): + total_comments = MethodField() + + def get_total_comments(self, obj): + # The "total_comments" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "total_comments", 0) or 0 diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py new file mode 100644 index 000000000..088c47cc3 --- /dev/null +++ b/taiga/projects/history/models.py @@ -0,0 +1,326 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.utils import timezone +from django.db import models +from django.contrib.auth import get_user_model +from django.utils.functional import cached_property +from taiga.base.db.models.fields import JSONField + +from taiga.mdrender.service import get_diff_of_htmls + +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 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 +IGNORE_DIFF_FIELDS = ["watchers", "description_diff", "content_diff", "blocked_note_diff"] + + +def _generate_uuid(): + return str(uuid.uuid1()) + + +class HistoryEntry(models.Model): + """ + Domain model that represents a history + entry storage table. + + It is used for store object changes and + comments. + """ + id = models.CharField(primary_key=True, max_length=255, unique=True, + editable=False, default=_generate_uuid) + project = models.ForeignKey("projects.Project", on_delete=models.CASCADE) + + user = JSONField(null=True, blank=True, default=None) + created_at = models.DateTimeField(default=timezone.now) + type = models.SmallIntegerField(choices=HISTORY_TYPE_CHOICES) + key = models.CharField(max_length=255, null=True, default=None, blank=True, db_index=True) + + # Stores the last diff + diff = JSONField(null=True, blank=True, default=None) + + # Stores the values_diff cache + values_diff_cache = JSONField(null=True, blank=True, default=None) + + # Stores the last complete frozen object snapshot + snapshot = JSONField(null=True, blank=True, default=None) + + # Stores a values of all identifiers used in + values = JSONField(null=True, blank=True, default=None) + + # Stores a comment + comment = models.TextField(blank=True) + comment_html = models.TextField(blank=True) + + delete_comment_date = models.DateTimeField(null=True, blank=True, default=None) + delete_comment_user = JSONField(null=True, blank=True, default=None) + + # Historic version of comments + comment_versions = JSONField(null=True, blank=True, default=None) + edit_comment_date = models.DateTimeField(null=True, blank=True, default=None) + + # Flag for mark some history entries as + # hidden. Hidden history entries are important + # for save but not important to preview. + # Order fields are the good example of this fields. + is_hidden = models.BooleanField(default=False) + + # Flag for mark some history entries as complete + # snapshot. The rest are partial snapshot. + is_snapshot = models.BooleanField(default=False) + + _importing = None + _owner = None + _prefetched_owner = False + + @cached_property + def is_change(self): + return self.type == HistoryType.change + + @cached_property + def is_create(self): + return self.type == HistoryType.create + + @cached_property + def is_delete(self): + return self.type == HistoryType.delete + + @property + def owner(self): + if not self._prefetched_owner: + pk = self.user["pk"] + model = get_user_model() + try: + owner = model.objects.get(pk=pk) + except model.DoesNotExist: + owner = None + + self.prefetch_owner(owner) + + return self._owner + + def prefetch_owner(self, owner): + self._owner = owner + self._prefetched_owner = True + + def attach_user_info_to_comment_versions(self): + if not self.comment_versions: + return + + from taiga.users.serializers import UserSerializer + + user_ids = [v["user"]["id"] for v in self.comment_versions if "user" in v and "id" in v["user"]] + users_by_id = {u.id: u for u in get_user_model().objects.filter(id__in=user_ids)} + + for version in self.comment_versions: + user = users_by_id.get(version["user"]["id"], None) + if user: + version["user"] = UserSerializer(user).data + + @property + def values_diff(self): + if self.values_diff_cache is not None: + return self.values_diff_cache + + result = {} + users_keys = ["assigned_to", "owner"] + + def resolve_diff_value(key): + value = None + diff = get_diff_of_htmls( + self.diff[key][0] or "", + self.diff[key][1] or "" + ) + + if diff: + key = "{}_diff".format(key) + value = (None, diff) + + return (key, value) + + def resolve_value(field, key): + data = self.values[field] + key = str(key) + + if key not in data: + return None + return data[key] + + for key in self.diff: + value = None + if key in IGNORE_DIFF_FIELDS: + continue + elif key in["description", "content", "blocked_note"]: + (key, value) = resolve_diff_value(key) + elif key in users_keys: + value = [resolve_value("users", x) for x in self.diff[key]] + elif key == "assigned_users": + diff_in, diff_out = self.diff[key] + value_in = None + value_out = None + + if diff_in: + users_list = [resolve_value("users", x) for x in diff_in if x] + value_in = ", ".join(filter(None, users_list)) + if diff_out: + users_list = [resolve_value("users", x) for x in diff_out if x] + value_out = ", ".join(filter(None, users_list)) + value = [value_in, value_out] + elif key == "points": + points = {} + + pointsold = self.diff["points"][0] + pointsnew = self.diff["points"][1] + # pointsold = pointsnew + + if pointsold is None: + for role_id, point_id in pointsnew.items(): + role_name = resolve_value("roles", role_id) + points[role_name] = [None, resolve_value("points", point_id)] + + else: + for role_id, point_id in pointsnew.items(): + role_name = resolve_value("roles", role_id) + oldpoint_id = pointsold.get(role_id, None) + points[role_name] = [resolve_value("points", oldpoint_id), + resolve_value("points", point_id)] + + # Process that removes points entries with + # duplicate value. + for role in dict(points): + values = points[role] + if values[1] == values[0]: + del points[role] + + if points: + value = points + + elif key == "attachments": + attachments = { + "new": [], + "changed": [], + "deleted": [], + } + + oldattachs = {x["id"]: x for x in self.diff["attachments"][0]} + newattachs = {x["id"]: x for x in self.diff["attachments"][1]} + + for aid in set(tuple(oldattachs.keys()) + tuple(newattachs.keys())): + if aid in oldattachs and aid in newattachs: + changes = make_diff_from_dicts(oldattachs[aid], newattachs[aid], + excluded_keys=("filename", "url", "thumb_url", "order")) + + if changes: + change = { + "filename": newattachs.get(aid, {}).get("filename", ""), + "url": newattachs.get(aid, {}).get("url", ""), + "thumb_url": newattachs.get(aid, {}).get("thumb_url", ""), + "changes": changes + } + attachments["changed"].append(change) + elif aid in oldattachs and aid not in newattachs: + attachments["deleted"].append(oldattachs[aid]) + elif aid not in oldattachs and aid in newattachs: + attachments["new"].append(newattachs[aid]) + + if attachments["new"] or attachments["changed"] or attachments["deleted"]: + value = attachments + + elif key == "custom_attributes": + custom_attributes = { + "new": [], + "changed": [], + "deleted": [], + } + + oldcustattrs = {x["id"]: x for x in self.diff["custom_attributes"][0] or []} + newcustattrs = {x["id"]: x for x in self.diff["custom_attributes"][1] or []} + + for aid in set(tuple(oldcustattrs.keys()) + tuple(newcustattrs.keys())): + if aid in oldcustattrs and aid in newcustattrs: + changes = make_diff_from_dicts(oldcustattrs[aid], newcustattrs[aid], + excluded_keys=("name", "type")) + newcustattr = newcustattrs.get(aid, {}) + if changes: + change_type = newcustattr.get("type", TEXT_TYPE) + + 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, + "type": change_type, + "value_diff": value_diff + } + custom_attributes["changed"].append(change) + elif aid in oldcustattrs and aid not in newcustattrs: + custom_attributes["deleted"].append(oldcustattrs[aid]) + elif aid not in oldcustattrs and aid in newcustattrs: + 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]) + + if custom_attributes["new"] or custom_attributes["changed"] or custom_attributes["deleted"]: + value = custom_attributes + + elif key == "user_stories": + user_stories = { + "new": [], + "deleted": [], + } + + olduss = {x["id"]: x for x in self.diff["user_stories"][0]} + newuss = {x["id"]: x for x in self.diff["user_stories"][1]} + + for usid in set(tuple(olduss.keys()) + tuple(newuss.keys())): + if usid in olduss and usid not in newuss: + user_stories["deleted"].append(olduss[usid]) + elif usid not in olduss and usid in newuss: + user_stories["new"].append(newuss[usid]) + + if user_stories["new"] or user_stories["deleted"]: + value = user_stories + + elif key in self.values: + value = [resolve_value(key, x) for x in self.diff[key]] + else: + value = self.diff[key] + + if not value: + continue + + result[key] = value + + self.values_diff_cache = result + # Update values_diff_cache without dispatching signals + HistoryEntry.objects.filter(pk=self.pk).update(values_diff_cache=self.values_diff_cache) + return self.values_diff_cache + + class Meta: + ordering = ["created_at"] diff --git a/taiga/projects/history/permissions.py b/taiga/projects/history/permissions.py new file mode 100644 index 000000000..ccedfa739 --- /dev/null +++ b/taiga/projects/history/permissions.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, + IsProjectAdmin, AllowAny, + IsObjectOwner, PermissionComponent) + +from taiga.permissions.services import is_project_admin +from taiga.projects.history.services import get_model_from_key, get_pk_from_key + + +class IsCommentDeleter(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return obj.delete_comment_user and obj.delete_comment_user.get("pk", "not-pk") == request.user.pk + + +class IsCommentOwner(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return obj.user and obj.user.get("pk", "not-pk") == request.user.pk + + +class IsCommentProjectAdmin(PermissionComponent): + def check_permissions(self, request, view, obj=None): + model = get_model_from_key(obj.key) + pk = get_pk_from_key(obj.key) + project = model.objects.get(pk=pk) + return is_project_admin(request.user, project) + + +class EpicHistoryPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + delete_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectAdmin() | IsCommentDeleter() + comment_versions_perms = IsCommentProjectAdmin() | IsCommentOwner() + + +class UserStoryHistoryPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + delete_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectAdmin() | IsCommentDeleter() + comment_versions_perms = IsCommentProjectAdmin() | IsCommentOwner() + + +class TaskHistoryPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + delete_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectAdmin() | IsCommentDeleter() + comment_versions_perms = IsCommentProjectAdmin() | IsCommentOwner() + + +class IssueHistoryPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + delete_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectAdmin() | IsCommentDeleter() + comment_versions_perms = IsCommentProjectAdmin() | IsCommentOwner() + + +class WikiHistoryPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + edit_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + delete_comment_perms = IsCommentProjectAdmin() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectAdmin() | IsCommentDeleter() + comment_versions_perms = IsCommentProjectAdmin() | IsCommentOwner() diff --git a/taiga/projects/history/serializers.py b/taiga/projects/history/serializers.py new file mode 100644 index 000000000..b333244a4 --- /dev/null +++ b/taiga/projects/history/serializers.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import I18NJSONField, Field, MethodField + +from taiga.users.services import get_user_photo_url +from taiga.users.gravatar import get_user_gravatar_id + + +HISTORY_ENTRY_I18N_FIELDS = ("points", "status", "severity", "priority", "type") + + +class HistoryEntrySerializer(serializers.LightSerializer): + id = Field() + user = MethodField() + created_at = Field() + type = Field() + key = Field() + diff = Field() + snapshot = Field() + values = Field() + values_diff = I18NJSONField() + comment = I18NJSONField() + comment_html = Field() + delete_comment_date = Field() + delete_comment_user = Field() + edit_comment_date = Field() + is_hidden = Field() + is_snapshot = Field() + + def get_user(self, entry): + user = {"pk": None, "username": None, "name": None, "photo": None, "is_active": False} + user.update(entry.user) + user["photo"] = get_user_photo_url(entry.owner) + user["gravatar_id"] = get_user_gravatar_id(entry.owner) + + if entry.owner: + user["is_active"] = entry.owner.is_active + + if entry.owner.is_active or entry.owner.is_system: + user["name"] = entry.owner.get_full_name() + user["username"] = entry.owner.username + + return user diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py new file mode 100644 index 000000000..c69724d04 --- /dev/null +++ b/taiga/projects/history/services.py @@ -0,0 +1,487 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +""" +This module contains a main domain logic for object history management. +This is possible example: + + from taiga.projects import history + + class ViewSet(restfw.ViewSet): + def create(request): + object = get_some_object() + history.freeze(object) + # Do something... + history.persist_history(object, user=request.user) +""" +import logging +from collections import namedtuple +from copy import deepcopy +from functools import partial +from functools import wraps + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.apps import apps +from django.db import transaction as tx +from django_pglocks import advisory_lock + +from taiga.mdrender.service import render as mdrender +from taiga.base.utils.db import get_typename_for_model_class +from taiga.base.utils.diff import make_diff as make_diff_from_dicts + +from .models import HistoryType + +# Freeze implementatitions +from .freeze_impl import project_freezer +from .freeze_impl import milestone_freezer +from .freeze_impl import epic_freezer +from .freeze_impl import epic_related_userstory_freezer +from .freeze_impl import userstory_freezer +from .freeze_impl import issue_freezer +from .freeze_impl import task_freezer +from .freeze_impl import wikipage_freezer + + +from .freeze_impl import project_values +from .freeze_impl import milestone_values +from .freeze_impl import epic_values +from .freeze_impl import epic_related_userstory_values +from .freeze_impl import userstory_values +from .freeze_impl import issue_values +from .freeze_impl import task_values +from .freeze_impl import wikipage_values + +# Type that represents a freezed object +FrozenObj = namedtuple("FrozenObj", ["key", "snapshot"]) +FrozenDiff = namedtuple("FrozenDiff", ["key", "diff", "snapshot"]) + +# Dict containing registred contentypes with their freeze implementation. +_freeze_impl_map = {} + +# Dict containing registred containing with their values implementation. +_values_impl_map = {} + +# Not important fields for models (history entries with only +# this fields are marked as hidden). +_not_important_fields = { + "epics.epic": frozenset(["epics_order", "user_stories"]), + "userstories.userstory": frozenset( + ["backlog_order", "sprint_order", "kanban_order"]), + "tasks.task": frozenset(["us_order", "taskboard_order"]), +} + +_deprecated_fields = { + "userstories.userstory": frozenset(["assigned_to"]), +} + +log = logging.getLogger("taiga.history") + + +def make_key_from_model_object(obj: object) -> str: + """ + Create unique key from model instance. + """ + tn = get_typename_for_model_class(obj.__class__) + return "{0}:{1}".format(tn, obj.pk) + + +def get_model_from_key(key: str) -> object: + """ + Get model from key + """ + class_name, pk = key.split(":", 1) + return apps.get_model(class_name) + + +def get_pk_from_key(key: str) -> object: + """ + Get pk from key + """ + class_name, pk = key.split(":", 1) + return pk + + +def get_instance_from_key(key: str) -> object: + """ + Get instance from key + """ + model = get_model_from_key(key) + pk = get_pk_from_key(key) + try: + obj = model.objects.get(pk=pk) + return obj + except model.DoesNotExist: + # Catch simultaneous DELETE request + return None + + +def register_values_implementation(typename: str, fn=None): + """ + Register values implementation for specified typename. + This function can be used as decorator. + """ + + assert isinstance(typename, str), "typename must be specied" + + if fn is None: + return partial(register_values_implementation, typename) + + @wraps(fn) + def _wrapper(*args, **kwargs): + return fn(*args, **kwargs) + + _values_impl_map[typename] = _wrapper + return _wrapper + + +def register_freeze_implementation(typename: str, fn=None): + """ + Register freeze implementation for specified typename. + This function can be used as decorator. + """ + + assert isinstance(typename, str), "typename must be specied" + + if fn is None: + return partial(register_freeze_implementation, typename) + + @wraps(fn) + def _wrapper(*args, **kwargs): + return fn(*args, **kwargs) + + _freeze_impl_map[typename] = _wrapper + return _wrapper + + +# Low level api + +def freeze_model_instance(obj: object) -> FrozenObj: + """ + Creates a new frozen object from model instance. + + The freeze process consists on converting model + instances to hashable plain python objects and + wrapped into FrozenObj. + """ + + model_cls = obj.__class__ + + # Additional query for test if object is really exists + # on the database or it is removed. + try: + obj = model_cls.objects.get(pk=obj.pk) + except model_cls.DoesNotExist: + return None + + typename = get_typename_for_model_class(model_cls) + if typename not in _freeze_impl_map: + raise RuntimeError("No implementation found for {}".format(typename)) + + key = make_key_from_model_object(obj) + impl_fn = _freeze_impl_map[typename] + snapshot = impl_fn(obj) + assert isinstance(snapshot, dict), \ + "freeze handlers should return always a dict" + + return FrozenObj(key, snapshot) + + +def is_hidden_snapshot(obj: FrozenDiff) -> bool: + """ + Check if frozen object is considered + hidden or not. + """ + content_type, pk = obj.key.rsplit(":", 1) + snapshot_fields = frozenset(obj.diff.keys()) + + if content_type not in _not_important_fields: + return False + + nfields = _not_important_fields[content_type] + result = snapshot_fields - nfields + + if snapshot_fields and len(result) == 0: + return True + + return False + + +def get_excluded_fields(typename: str) -> tuple: + """ + Get excluded and deprected fields to avoid in the diff + """ + return _deprecated_fields.get(typename, ()) + + +def migrate_userstory_diff(obj: FrozenObj) -> FrozenObj: + # Due to multiple assignment migration, for old snapshots we add a list + # with the 'assigned to' value + if 'assigned_users' not in obj.snapshot.keys(): + snapshot = deepcopy(obj.snapshot) + snapshot['assigned_users'] = [obj.snapshot['assigned_to']] \ + if obj.snapshot['assigned_to'] else [] + + obj = FrozenObj(obj.key, snapshot) + + return obj + + +_migrations = {"userstories.userstory": migrate_userstory_diff} + + +def migrate_to_last_version(typename: str, obj: FrozenObj) -> FrozenObj: + """"" + Adapt old snapshots to the last format in order to generate correct diffs. + :param typename: + :param obj: + :return: + """ + return _migrations.get(typename, lambda x: x)(obj) + + +def make_diff(oldobj: FrozenObj, newobj: FrozenObj, + excluded_keys: tuple = ()) -> FrozenDiff: + """ + Compute a diff between two frozen objects. + """ + + assert isinstance(newobj, FrozenObj), \ + "newobj parameter should be instance of FrozenObj" + + if oldobj is None: + return FrozenDiff(newobj.key, {}, newobj.snapshot) + + first = oldobj.snapshot + second = newobj.snapshot + + # The object's attachments are manually handled to avoid considering changes in their URL's token as a user activity + # (just when the `taiga-protected` module is enabled) + diff = make_diff_from_dicts(first, second, None, frozenset().union(excluded_keys, frozenset(["attachments"]))) + attach_diffs = _make_diff_in_attachments(first, second) + if attach_diffs: + diff["attachments"] = attach_diffs + + return FrozenDiff(newobj.key, diff, newobj.snapshot) + + +def _make_diff_in_attachments(first_snapshot, second_snapshot): + if "attachments" in first_snapshot: + old_attachments = {x["id"]: x for x in first_snapshot["attachments"]} + new_attachments = {x["id"]: x for x in second_snapshot["attachments"]} + snapshot_attachments_tuple = first_snapshot["attachments"], second_snapshot["attachments"] + + for attach_id in set(tuple(old_attachments.keys()) + tuple(new_attachments.keys())): + if attach_id in old_attachments and attach_id in new_attachments: + attachments_changed = make_diff_from_dicts(old_attachments[attach_id], new_attachments[attach_id], + excluded_keys=("filename", "url", "thumb_url", "order")) + if attachments_changed: + return snapshot_attachments_tuple + elif attach_id in old_attachments and attach_id not in new_attachments: + return snapshot_attachments_tuple + elif attach_id not in old_attachments and attach_id in new_attachments: + return snapshot_attachments_tuple + + return None + + +def make_diff_values(typename: str, fdiff: FrozenDiff) -> dict: + """ + Given a typename and diff, build a values dict for it. + If no implementation found for typename, warnig is raised in + logging and returns empty dict. + """ + + if typename not in _values_impl_map: + log.warning( + "No implementation found of '{}' for values.".format(typename)) + return {} + + impl_fn = _values_impl_map[typename] + return impl_fn(fdiff.diff) + + +def _rebuild_snapshot_from_diffs(keysnapshot, partials): + result = deepcopy(keysnapshot) + + for part in partials: + for key, value in part.diff.items(): + result[key] = value[1] + + return result + + +def get_last_snapshot_for_key(key: str) -> FrozenObj: + entry_model = apps.get_model("history", "HistoryEntry") + + # Search last snapshot + qs = (entry_model.objects + .filter(key=key, is_snapshot=True) + .order_by("-created_at")) + + keysnapshot = qs.first() + if keysnapshot is None: + return None, True + + # Get all partial snapshots + entries = tuple(entry_model.objects + .filter(key=key, is_snapshot=False) + .filter(created_at__gte=keysnapshot.created_at) + .order_by("created_at")) + + snapshot = _rebuild_snapshot_from_diffs(keysnapshot.snapshot, entries) + max_partial_diffs = getattr(settings, "MAX_PARTIAL_DIFFS", 60) + + if len(entries) >= max_partial_diffs: + return FrozenObj(keysnapshot.key, snapshot), True + + return FrozenObj(keysnapshot.key, snapshot), False + + +# Public api + +def get_modified_fields(obj: object, last_modifications): + """ + Get the modified fields for an object through his last modifications + """ + key = make_key_from_model_object(obj) + entry_model = apps.get_model("history", "HistoryEntry") + history_entries = ( + entry_model.objects.filter(key=key) + .order_by("-created_at") + .values_list("diff", + flat=True)[0:last_modifications] + ) + + modified_fields = [] + for history_entry in history_entries: + modified_fields += history_entry.keys() + + return modified_fields + + +@tx.atomic +def take_snapshot(obj: object, *, comment: str="", user=None, + delete: bool=False): + """ + Given any model instance with registred content type, + create new history entry of "change" type. + + This raises exception in case of object wasn't + previously freezed. + """ + + key = make_key_from_model_object(obj) + with advisory_lock("history-"+key): + typename = get_typename_for_model_class(obj.__class__) + + new_fobj = freeze_model_instance(obj) + old_fobj, need_real_snapshot = get_last_snapshot_for_key(key) + + # migrate diff to latest schema + if old_fobj: + old_fobj = migrate_to_last_version(typename, old_fobj) + + entry_model = apps.get_model("history", "HistoryEntry") + user_id = None if user is None else user.id + user_name = "" if user is None else user.get_full_name() + + # Determine history type + if delete: + entry_type = HistoryType.delete + need_real_snapshot = True + elif new_fobj and not old_fobj: + entry_type = HistoryType.create + elif new_fobj and old_fobj: + entry_type = HistoryType.change + else: + raise RuntimeError("Unexpected condition") + + excluded_fields = get_excluded_fields(typename) + + fdiff = make_diff(old_fobj, new_fobj, excluded_fields) + + # If diff and comment are empty, do + # not create empty history entry + if (not fdiff.diff and + not comment and old_fobj is not None and + entry_type != HistoryType.delete): + return None + + fvals = make_diff_values(typename, fdiff) + + if len(comment) > 0: + is_hidden = False + else: + is_hidden = is_hidden_snapshot(fdiff) + + kwargs = { + "user": {"pk": user_id, "name": user_name}, + "project_id": getattr(obj, 'project_id', getattr(obj, 'id', None)), + "key": key, + "type": entry_type, + "snapshot": fdiff.snapshot if need_real_snapshot else None, + "diff": fdiff.diff, + "values": fvals, + "comment": comment, + "comment_html": mdrender(obj.project, comment), + "is_hidden": is_hidden, + "is_snapshot": need_real_snapshot, + } + + return entry_model.objects.create(**kwargs) + + +# High level query api + +def get_history_queryset_by_model_instance(obj: object, + types=(HistoryType.change,), + include_hidden=False): + """ + Get one page of history for specified object. + """ + key = make_key_from_model_object(obj) + history_entry_model = apps.get_model("history", "HistoryEntry") + + qs = history_entry_model.objects.filter(key=key, type__in=types) + if not include_hidden: + qs = qs.filter(is_hidden=False) + + return qs.order_by("created_at") + + +def prefetch_owners_in_history_queryset(qs): + user_ids = [u["pk"] for u in qs.values_list("user", flat=True)] + users = get_user_model().objects.filter(id__in=user_ids) + users_by_id = {u.id: u for u in users} + for history_entry in qs: + history_entry.prefetch_owner(users_by_id.get(history_entry.user["pk"], + None)) + + return qs + + +# Freeze & value register +register_freeze_implementation("projects.project", project_freezer) +register_freeze_implementation("milestones.milestone", milestone_freezer) +register_freeze_implementation("epics.epic", epic_freezer) +register_freeze_implementation("epics.relateduserstory", + epic_related_userstory_freezer) +register_freeze_implementation("userstories.userstory", userstory_freezer) +register_freeze_implementation("issues.issue", issue_freezer) +register_freeze_implementation("tasks.task", task_freezer) +register_freeze_implementation("wiki.wikipage", wikipage_freezer) + +register_values_implementation("projects.project", project_values) +register_values_implementation("milestones.milestone", milestone_values) +register_values_implementation("epics.epic", epic_values) +register_values_implementation("epics.relateduserstory", + epic_related_userstory_values) +register_values_implementation("userstories.userstory", userstory_values) +register_values_implementation("issues.issue", issue_values) +register_values_implementation("tasks.task", task_values) +register_values_implementation("wiki.wikipage", wikipage_values) diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja new file mode 100644 index 000000000..11f4bd4fc --- /dev/null +++ b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja @@ -0,0 +1,300 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% set excluded_fields = [ + "description", + "description_html", + "blocked_note", + "blocked_note_html", + "content", + "content_html", + "epics_order", + "backlog_order", + "kanban_order", + "sprint_order", + "taskboard_order", + "us_order", + "custom_attributes", + "tribe_gig", +] %} + +{% for field_name, values in changed_fields.items() %} + {% if field_name not in excluded_fields %} + {# POINTS #} + {% if field_name == "points" %} + {% for role, points in values.items() %} + + +

{% trans role=role %}{{ role }} role points{% endtrans %}

+ + + {{ _("from") }}
+ {{ points.0 }} + + + + + {{ _("to") }}
+ {{ points.1 }} + + + {% endfor %} + + {# ATTACHMENTS #} + {% elif field_name == "attachments" %} + {% if values.new %} + {% for att in values['new']%} + + +

{{ _("Added new attachment") }}

+

+ + {{ att.filename }} + +

+ {% if att.description %} +

{{ att.description }}

+ {% endif %} + + + {% endfor %} + {% endif %} + + {% if values.changed %} + {% for att in values['changed'] %} + + +

{{ _("Updated attachment") }}

+

+ + {{ att.filename|linebreaksbr }} + {% if att.changes.is_deprecated %} + {% if att.changes.is_deprecated.1 %} + [{{ _("deprecated") }}] + {% else %} + [{{ _("not deprecated") }}] + {% endif %} + {% endif %} + +

+ {% if att.changes.description %} +

{{ att.changes.description.1 }}

+ {% endif %} + + + {% endfor %} + {% endif %} + {% if values.deleted %} + {% for att in values['deleted']%} + + +

{{ _("Deleted attachment") }}

+

{{ att.filename|linebreaksbr }}

+ + + {% endfor %} + {% endif %} + {# TAGS AND WATCHERS #} + {% elif field_name in ["tags", "watchers"] %} + {% set values_from = values.0 or [] %} + {% set values_to = values.1 or [] %} + {% set values_added = lists_diff(values_to, values_from) %} + {% set values_removed = lists_diff(values_from, values_to) %} + + + +

{{ verbose_name(obj_class, field_name) }}

+ + + {% if values_added %} + {{ _("added") }}
+ {{ ', '.join(values_added) }} + {% endif %} + + {% if values_removed %} + {{ _("removed") }}
+ {{ ', '.join(values_removed) }} + {% endif %} + + + {# DESCRIPTIONS, CONTENT, BLOCKED_NOTE #} + {% elif field_name in ["description_diff", "content_diff", "blocked_note_diff"] %} + + +

{{ verbose_name(obj_class, field_name) }}

+

{{ mdrender(project, values.1) }}

+ + + {# ASSIGNED TO #} + {% elif field_name == "assigned_to" %} + + +

{{ verbose_name(obj_class, field_name) }}

+ + + {% if values.0 != None and values.0 != "" %} + {{ _("from") }}
+ {{ values.0 }} + {% else %} + {{ _("from") }}
+ {{ _("Unassigned") }} + {% endif %} + + + + + {% if values.1 != None and values.1 != "" %} + {{ _("to") }}
+ {{ values.1 }} + {% else %} + {{ _("to") }}
+ {{ _("Unassigned") }} + {% endif %} + + + {# DUE DATE #} + {% elif field_name == "due_date" %} + + +

{{ verbose_name(obj_class, field_name) }}

+ + + {% if values.0 != None and values.0 != "" %} + {{ _("from") }}
+ {{ values.0|parse_and_format_date }} + {% else %} + {{ _("from") }}
+ {{ _("Not set") }} + {% endif %} + + + + + {% if values.1 != None and values.1 != "" %} + {{ _("to") }}
+ {{ values.1|parse_and_format_date }} + {% else %} + {{ _("to") }}
+ {{ _("Not set") }} + {% endif %} + + + {# ASSIGNED users #} + {% elif field_name == "assigned_users" %} + + +

{{ verbose_name(obj_class, field_name) }}

+ + + {% if values.0 != None and values.0 != "" %} + {{ _("from") }}
+ {{ values.0 }} + {% else %} + {{ _("from") }}
+ {{ _("Unassigned") }} + {% endif %} + + + + + {% if values.1 != None and values.1 != "" %} + {{ _("to") }}
+ {{ values.1 }} + {% else %} + {{ _("to") }}
+ {{ _("Unassigned") }} + {% endif %} + + + {# * #} + {% else %} + + +

{{ verbose_name(obj_class, field_name) }}

+ + + {{ _("from") }}
+ {{ values.0|linebreaksbr }} + + + + + {{ _("to") }}
+ {{ values.1|linebreaksbr }} + + + {% endif %} + + {% elif field_name == "custom_attributes" %} + {# CUSTOM ATTRIBUTES #} + {% if values.new %} + {% for attr in values['new']%} + {% if attr.type == "richtext" %} + + +

{{ attr.name }}

+

{{ mdrender(project, attr.value_diff) }}

+ + + {% else %} + + +

{{ attr.name }}

+ + + + + {{ _("to") }}
+ {{ attr.value|linebreaksbr }} + + + {% endif %} + {% endfor %} + {% endif %} + {% if values.changed %} + {% for attr in values['changed'] %} + {% if attr.changes.value%} + {% if attr.type == "richtext" %} + + +

{{ attr.name }}

+

{{ mdrender(project, attr.value_diff) }}

+ + + {% else %} + + +

{{ attr.name }}

+ + + {{ _("from") }}
+ {{ attr.changes.value.0|linebreaksbr }} + + + + + {{ _("to") }}
+ {{ attr.changes.value.1|linebreaksbr }} + + + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + {% if values.deleted %} + {% for attr in values['deleted']%} + + +

{{ attr.name }}

+

{{ _("-deleted-") }}

+ + + {% endfor %} + {% endif %} + {% endif %} +{% endfor %} diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja new file mode 100644 index 000000000..906cb67e5 --- /dev/null +++ b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja @@ -0,0 +1,104 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% set excluded_fields = [ + "description_diff", + "description_html", + "content_diff", + "content_html", + "epics_order", + "backlog_order", + "kanban_order", + "sprint_order", + "taskboard_order", + "us_order", + "blocked_note_diff", + "blocked_note_html", + "custom_attributes", + "tribe_gig", +] %} +{% for field_name, values in changed_fields.items() %} + {% if field_name not in excluded_fields %} + - {{ verbose_name(obj_class, field_name) }}: + {# POINTS #} + {% if field_name == "points" %} + {% for role, points in values.items() %} + * {{ role }} {{ _("to:") }} {{ points.1 }} {{ _("from:") }} {{ points.0 }} + {% endfor %} + + {# ATTACHMENTS #} + {% elif field_name == "attachments" %} + {% if values.new %} + * {{ _("Added") }}: + {% for att in values['new']%} + - {{ att.filename }} + {% endfor %} + {% endif %} + + {% if values.changed %} + * {{ _("Changed") }} + {% for att in values['changed'] %} + - {{ att.filename }} + {% endfor %} + {% endif %} + + {% if values.deleted %} + * {{ _("Deleted") }} + {% for att in values['deleted']%} + - {{ att.filename }} + {% endfor %} + {% endif %} + + {# TAGS AND WATCHERS #} + {% elif field_name in ["tags", "watchers"] %} + {% set values_from = values.0 or [] %} + {% set values_to = values.1 or [] %} + {% set values_added = lists_diff(values_to, values_from) %} + {% set values_removed = lists_diff(values_from, values_to) %} + + {% if values_added %} + * {{ _("added:") }} {{ ', '.join(values_added) }} + {% endif %} + {% if values_removed %} + * {{ _("removed:") }} {{ ', '.join(values_removed) }} + {% endif %} + + {# * #} + {% else %} + * {{ _("From:") }} {{ values.0 }} + * {{ _("To:") }} {{ values.1 }} + {% endif %} + + {% elif field_name == "custom_attributes" %} + {# CUSTOM ATTRIBUTES #} + {% if values.new %} + {% for attr in values['new']%} + + - {{ attr.name }}: + * {{ attr.value }} + {% endfor %} + {% endif %} + + {% if values.changed %} + {% for attr in values['changed'] %} + {% if attr.changes.value%} + - {{ attr.name }}: + * {{ _("From:") }} {{ attr.changes.value.0 }} + * {{ _("To:") }} {{ attr.changes.value.1 }} + {% endif %} + {% endfor %} + {% endif %} + + {% if values.deleted %} + {% for attr in values['deleted']%} + - {{ attr.name }}: {{ _("-deleted-") }} + * {{ attr.value }} + {% endfor %} + {% endif %} + {% endif %} +{% endfor %} diff --git a/taiga/projects/history/templatetags/__init__.py b/taiga/projects/history/templatetags/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/history/templatetags/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/history/templatetags/functions.py b/taiga/projects/history/templatetags/functions.py new file mode 100644 index 000000000..b73dbb2aa --- /dev/null +++ b/taiga/projects/history/templatetags/functions.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext_lazy as _ + +from django_jinja import library + + +EXTRA_FIELD_VERBOSE_NAMES = { + "description_diff": _("description"), + "content_diff": _("content"), + "blocked_note_diff": _("blocked note"), + "milestone": _("sprint"), +} + + +@library.global_function +def verbose_name(obj_class, field_name): + if field_name in EXTRA_FIELD_VERBOSE_NAMES: + return EXTRA_FIELD_VERBOSE_NAMES[field_name] + + try: + return obj_class._meta.get_field(field_name).verbose_name + except Exception: + return field_name + + +@library.global_function +def lists_diff(list1, list2): + """ + Get the difference of two list and remove None values. + + >>> list1 = ["a", None, "b", "c"] + >>> list2 = [None, "b", "d", "e"] + >>> list(filter(None.__ne__, set(list1) - set(list2))) + ['c', 'a'] + """ + return list(filter(None.__ne__, set(list1) - set(list2))) diff --git a/taiga/projects/history/utils.py b/taiga/projects/history/utils.py new file mode 100644 index 000000000..ae225b3c2 --- /dev/null +++ b/taiga/projects/history/utils.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.projects.history.services import get_typename_for_model_class + + +def attach_total_comments_to_queryset(queryset, as_field="total_comments"): + """Attach a total comments counter to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the counter as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT COUNT(history_historyentry.id) + FROM history_historyentry + WHERE history_historyentry.key = CONCAT('{key_prefix}', {tbl}.id) AND + history_historyentry.comment is not null AND + history_historyentry.comment != '' + """ + + typename = get_typename_for_model_class(model) + + sql = sql.format(tbl=model._meta.db_table, key_prefix="{}:".format(typename)) + + queryset = queryset.extra(select={as_field: sql}) + return queryset diff --git a/taiga/projects/issues/__init__.py b/taiga/projects/issues/__init__.py new file mode 100644 index 000000000..e6281a1af --- /dev/null +++ b/taiga/projects/issues/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + diff --git a/taiga/projects/issues/admin.py b/taiga/projects/issues/admin.py new file mode 100644 index 000000000..c53043433 --- /dev/null +++ b/taiga/projects/issues/admin.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin + +from taiga.projects.attachments.admin import AttachmentInline +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + +from . import models + + +class IssueAdmin(admin.ModelAdmin): + list_display = ["project", "milestone", "ref", "subject",] + list_display_links = ["ref", "subject",] + inlines = [WatchedInline, VoteInline] + raw_id_fields = ["project"] + search_fields = ["subject", "description", "id", "ref"] + + def get_object(self, *args, **kwargs): + self.obj = super().get_object(*args, **kwargs) + return self.obj + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if (db_field.name in ["status", "priority", "severity", "type", "milestone"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter( + project=self.obj.project) + elif (db_field.name in ["owner", "assigned_to"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.obj.project) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + if (db_field.name in ["watchers"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related.parent_model.objects.filter( + memberships__project=self.obj.project) + return super().formfield_for_manytomany(db_field, request, **kwargs) + + +admin.site.register(models.Issue, IssueAdmin) diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py new file mode 100644 index 000000000..4814f071a --- /dev/null +++ b/taiga/projects/issues/api.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.utils.translation import gettext as _ +from django.http import HttpResponse + +from taiga.base import filters +from taiga.base import exceptions as exc +from taiga.base import response +from taiga.base.decorators import detail_route, list_route +from taiga.base.api import ModelCrudViewSet, ModelListViewSet +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.api.utils import get_object_or_error + +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.mixins.promote import PromoteToUserStoryMixin +from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType +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 + +from .utils import attach_extra_info + +from . import models +from . import services +from . import permissions +from . import serializers +from . import validators + + +class IssueViewSet(AssignedToSignalMixin, OCCResourceMixin, VotedResourceMixin, + HistoryResourceMixin, WatchedResourceMixin, ByRefMixin, + TaggedResourceMixin, BlockedByProjectMixin, PromoteToUserStoryMixin, + ModelCrudViewSet): + validator_class = validators.IssueValidator + queryset = models.Issue.objects.all() + permission_classes = (permissions.IssuePermission, ) + filter_backends = (filters.CanViewIssuesFilterBackend, + filters.RoleFilter, + filters.OwnersFilter, + filters.AssignedToFilter, + filters.StatusesFilter, + filters.IssueTypesFilter, + filters.SeveritiesFilter, + filters.PrioritiesFilter, + filters.TagsFilter, + filters.WatchersFilter, + filters.QFilter, + filters.CreatedDateFilter, + filters.ModifiedDateFilter, + filters.FinishedDateFilter, + filters.OrderByFilterMixin) + filter_fields = ("milestone", + "project", + "project__slug", + "status__is_closed") + order_by_fields = ("type", + "project", + "status", + "severity", + "priority", + "created_date", + "modified_date", + "owner", + "assigned_to", + "subject", + "total_voters", + "ref") + + def get_serializer_class(self, *args, **kwargs): + if self.action in ["retrieve", "by_ref"]: + return serializers.IssueNeighborsSerializer + + if self.action == "list": + return serializers.IssueListSerializer + + return serializers.IssueSerializer + + def update(self, request, *args, **kwargs): + self.object = self.get_object_or_none() + project_id = request.DATA.get('project', None) + if project_id and self.object and self.object.project.id != project_id: + try: + new_project = Project.objects.get(pk=project_id) + self.check_permissions(request, "destroy", self.object) + self.check_permissions(request, "create", new_project) + + sprint_id = request.DATA.get('milestone', None) + if sprint_id is not None and new_project.milestones.filter(pk=sprint_id).count() == 0: + request.DATA['milestone'] = None + + status_id = request.DATA.get('status', None) + if status_id is not None: + try: + old_status = self.object.project.issue_statuses.get(pk=status_id) + new_status = new_project.issue_statuses.get(slug=old_status.slug) + request.DATA['status'] = new_status.id + except IssueStatus.DoesNotExist: + request.DATA['status'] = new_project.default_issue_status.id + + priority_id = request.DATA.get('priority', None) + if priority_id is not None: + try: + old_priority = self.object.project.priorities.get(pk=priority_id) + new_priority = new_project.priorities.get(name=old_priority.name) + request.DATA['priority'] = new_priority.id + except Priority.DoesNotExist: + request.DATA['priority'] = new_project.default_priority.id + + severity_id = request.DATA.get('severity', None) + if severity_id is not None: + try: + old_severity = self.object.project.severities.get(pk=severity_id) + new_severity = new_project.severities.get(name=old_severity.name) + request.DATA['severity'] = new_severity.id + except Severity.DoesNotExist: + request.DATA['severity'] = new_project.default_severity.id + + type_id = request.DATA.get('type', None) + if type_id is not None: + try: + old_type = self.object.project.issue_types.get(pk=type_id) + new_type = new_project.issue_types.get(name=old_type.name) + request.DATA['type'] = new_type.id + except IssueType.DoesNotExist: + request.DATA['type'] = new_project.default_issue_type.id + + except Project.DoesNotExist: + return response.BadRequest(_("The project doesn't exist")) + + return super().update(request, *args, **kwargs) + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.select_related("owner", "assigned_to", "status", "project") + + include_attachments = "include_attachments" in self.request.QUERY_PARAMS + qs = attach_extra_info(qs, user=self.request.user, + include_attachments=include_attachments) + + return qs + + def pre_save(self, obj): + if not obj.id: + obj.owner = self.request.user + + super().pre_save(obj) + + def pre_conditions_on_save(self, obj): + if obj.milestone and obj.milestone.project != obj.project: + raise exc.PermissionDenied(_("You don't have permissions to set this sprint " + "to this issue.")) + + if obj.status and obj.status.project != obj.project: + raise exc.PermissionDenied(_("You don't have permissions to set this status " + "to this issue.")) + + if obj.severity and obj.severity.project != obj.project: + raise exc.PermissionDenied(_("You don't have permissions to set this severity " + "to this issue.")) + + if obj.priority and obj.priority.project != obj.project: + raise exc.PermissionDenied(_("You don't have permissions to set this priority " + "to this issue.")) + + if obj.type and obj.type.project != obj.project: + raise exc.PermissionDenied(_("You don't have permissions to set this type " + "to this issue.")) + + super().pre_conditions_on_save(obj) + + @list_route(methods=["GET"]) + def filters_data(self, request, *args, **kwargs): + project_id = request.QUERY_PARAMS.get("project", None) + project = get_object_or_error(Project, request.user, id=project_id) + + filter_backends = self.get_filter_backends() + types_filter_backends = (f for f in filter_backends if f != filters.IssueTypesFilter) + statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter) + assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter) + owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter) + priorities_filter_backends = (f for f in filter_backends if f != filters.PrioritiesFilter) + severities_filter_backends = (f for f in filter_backends if f != filters.SeveritiesFilter) + roles_filter_backends = (f for f in filter_backends if f != filters.RoleFilter) + tags_filter_backends = (f for f in filter_backends if f != filters.TagsFilter) + + queryset = self.get_queryset() + querysets = { + "types": self.filter_queryset(queryset, filter_backends=types_filter_backends), + "statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends), + "assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends), + "owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends), + "priorities": self.filter_queryset(queryset, filter_backends=priorities_filter_backends), + "severities": self.filter_queryset(queryset, filter_backends=severities_filter_backends), + "tags": self.filter_queryset(queryset, filter_backends=tags_filter_backends), + "roles": self.filter_queryset(queryset, filter_backends=roles_filter_backends), + } + return response.Ok(services.get_issues_filters_data(project, querysets)) + + @list_route(methods=["GET"]) + def csv(self, request): + uuid = request.QUERY_PARAMS.get("uuid", None) + if uuid is None: + return response.NotFound() + + project = get_object_or_error(Project, request.user, issues_csv_uuid=uuid) + queryset = project.issues.all().order_by('ref') + data = services.issues_to_csv(project, queryset) + csv_response = HttpResponse(data.getvalue(), content_type='application/csv; charset=utf-8') + csv_response['Content-Disposition'] = 'attachment; filename="issues.csv"' + return csv_response + + @list_route(methods=["POST"]) + def bulk_create(self, request, **kwargs): + validator = validators.IssuesBulkValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data + project = Project.objects.get(pk=data["project_id"]) + self.check_permissions(request, 'bulk_create', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + issues = services.create_issues_in_bulk( + data["bulk_issues"], milestone_id=data["milestone_id"], + project=project, owner=request.user, + status=project.default_issue_status, + severity=project.default_severity, + priority=project.default_priority, + type=project.default_issue_type, + callback=self.post_save, precall=self.pre_save) + + issues = self.get_queryset().filter(id__in=[i.id for i in issues]) + issues_serialized = self.get_serializer_class()(issues, many=True) + + return response.Ok(data=issues_serialized.data) + + 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_error(Project, request.user, pk=data["project_id"]) + milestone = get_object_or_error(Milestone, request.user, 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,) + resource_model = models.Issue + + +class IssueWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.IssueWatchersPermission,) + resource_model = models.Issue diff --git a/taiga/projects/issues/apps.py b/taiga/projects/issues/apps.py new file mode 100644 index 000000000..cb085b759 --- /dev/null +++ b/taiga/projects/issues/apps.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.apps import AppConfig +from django.apps import apps +from django.db.models import signals + + +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"), + dispatch_uid="set_finished_date_when_edit_issue") + + # Tags + signals.pre_save.connect(tagging_handlers.tags_normalization, + 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 + + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_issue, + sender=apps.get_model("issues", "Issue"), + dispatch_uid="create_custom_attribute_value_when_create_issue") + + +def connect_all_issues_signals(): + connect_issues_signals() + connect_issues_custom_attributes_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"), + dispatch_uid="create_custom_attribute_value_when_create_issue") + + +def disconnect_all_issues_signals(): + disconnect_issues_signals() + disconnect_issues_custom_attributes_signals() + + +class IssuesAppConfig(AppConfig): + name = "taiga.projects.issues" + verbose_name = "Issues" + watched_types = ["issues.issue", ] + + def ready(self): + connect_all_issues_signals() diff --git a/taiga/projects/issues/migrations/0001_initial.py b/taiga/projects/issues/migrations/0001_initial.py new file mode 100644 index 000000000..a2468d362 --- /dev/null +++ b/taiga/projects/issues/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import django.utils.timezone +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('milestones', '__first__'), + ('projects', '0002_auto_20140903_0920'), + ] + + operations = [ + migrations.CreateModel( + name='Issue', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, verbose_name='ID', primary_key=True)), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=list, null=True, size=None, verbose_name='tags')), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('is_blocked', models.BooleanField(default=False, verbose_name='is blocked')), + ('blocked_note', models.TextField(blank=True, default='', verbose_name='blocked note')), + ('ref', models.BigIntegerField(null=True, blank=True, db_index=True, default=None, verbose_name='ref')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('finished_date', models.DateTimeField(blank=True, null=True, verbose_name='finished date')), + ('subject', models.TextField(verbose_name='subject')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('assigned_to', models.ForeignKey(blank=True, null=True, verbose_name='assigned to', to=settings.AUTH_USER_MODEL, default=None, related_name='issues_assigned_to_me', on_delete=models.SET_NULL)), + ('milestone', models.ForeignKey(blank=True, null=True, verbose_name='milestone', to='milestones.Milestone', default=None, related_name='issues', on_delete=models.SET_NULL)), + ('owner', models.ForeignKey(blank=True, null=True, verbose_name='owner', to=settings.AUTH_USER_MODEL, default=None, related_name='owned_issues', on_delete=models.SET_NULL)), + ('priority', models.ForeignKey(verbose_name='priority', to='projects.Priority', related_name='issues', on_delete=models.SET_NULL)), + ('project', models.ForeignKey(verbose_name='project', to='projects.Project', related_name='issues', on_delete=models.CASCADE)), + ('severity', models.ForeignKey(verbose_name='severity', to='projects.Severity', related_name='issues', on_delete=models.SET_NULL)), + ('status', models.ForeignKey(verbose_name='status', to='projects.IssueStatus', related_name='issues', on_delete=models.SET_NULL)), + ('type', models.ForeignKey(verbose_name='type', to='projects.IssueType', related_name='issues', on_delete=models.SET_NULL)), + ('watchers', models.ManyToManyField(to=settings.AUTH_USER_MODEL, blank=True, null=True, related_name='issues_issue+', verbose_name='watchers')), + ], + options={ + 'verbose_name_plural': 'issues', + 'ordering': ['project', '-created_date'], + 'verbose_name': 'issue', + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/projects/issues/migrations/0002_issue_external_reference.py b/taiga/projects/issues/migrations/0002_issue_external_reference.py new file mode 100644 index 000000000..ecf18f722 --- /dev/null +++ b/taiga/projects/issues/migrations/0002_issue_external_reference.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='issue', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=False, null=False), blank=True, default=None, null=True, size=None, verbose_name='external reference'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/issues/migrations/0003_auto_20141210_1108.py b/taiga/projects/issues/migrations/0003_auto_20141210_1108.py new file mode 100644 index 000000000..65c220ee6 --- /dev/null +++ b/taiga/projects/issues/migrations/0003_auto_20141210_1108.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db import connection +from taiga.projects.userstories.models import * +from taiga.projects.tasks.models import * +from taiga.projects.issues.models import * +from taiga.projects.models import * + +def _fix_tags_model(tags_model): + table_name = tags_model._meta.db_table + query = "select id from (select id, unnest(tags) tag from %s) x where tag LIKE '%%,%%'"%(table_name) + cursor = connection.cursor() + cursor.execute(query) + for row in cursor.fetchall(): + id = row[0] + instance = tags_model.objects.get(id=id) + instance.tags = [tag.replace(",", "") for tag in instance.tags] + instance.save() + + +def fix_tags(apps, schema_editor): + _fix_tags_model(Issue) + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0002_issue_external_reference'), + ] + + operations = [ + migrations.RunPython(fix_tags), + ] diff --git a/taiga/projects/issues/migrations/0004_auto_20150114_0954.py b/taiga/projects/issues/migrations/0004_auto_20150114_0954.py new file mode 100644 index 000000000..f7876ce49 --- /dev/null +++ b/taiga/projects/issues/migrations/0004_auto_20150114_0954.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0003_auto_20141210_1108'), + ] + + operations = [ + migrations.AlterModelOptions( + name='issue', + options={'ordering': ['project', '-id'], 'verbose_name_plural': 'issues', 'verbose_name': 'issue'}, + ), + ] diff --git a/taiga/projects/issues/migrations/0005_auto_20150623_1923.py b/taiga/projects/issues/migrations/0005_auto_20150623_1923.py new file mode 100644 index 000000000..dc2f82a9c --- /dev/null +++ b/taiga/projects/issues/migrations/0005_auto_20150623_1923.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0004_auto_20150114_0954'), + ] + + operations = [ + migrations.AlterField( + model_name='issue', + name='priority', + field=models.ForeignKey(blank=True, null=True, to='projects.Priority', related_name='issues', verbose_name='priority', on_delete=models.SET_NULL), + preserve_default=True, + ), + migrations.AlterField( + model_name='issue', + name='severity', + field=models.ForeignKey(blank=True, null=True, to='projects.Severity', related_name='issues', verbose_name='severity', on_delete=models.CASCADE), + preserve_default=True, + ), + migrations.AlterField( + model_name='issue', + name='status', + field=models.ForeignKey(blank=True, null=True, to='projects.IssueStatus', related_name='issues', verbose_name='status', on_delete=models.CASCADE), + preserve_default=True, + ), + migrations.AlterField( + model_name='issue', + name='type', + field=models.ForeignKey(blank=True, null=True, to='projects.IssueType', related_name='issues', verbose_name='type', on_delete=models.CASCADE), + preserve_default=True, + ), + ] diff --git a/taiga/projects/issues/migrations/0006_remove_issue_watchers.py b/taiga/projects/issues/migrations/0006_remove_issue_watchers.py new file mode 100644 index 000000000..cffd6b577 --- /dev/null +++ b/taiga/projects/issues/migrations/0006_remove_issue_watchers.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import connection +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from taiga.base.utils.contenttypes import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes(verbosity=0) + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT issue_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM issues_issue_watchers INNER JOIN issues_issue ON issues_issue_watchers.issue_id = issues_issue.id""".format(content_type_id=ContentType.objects.get(model='issue').id) + cursor = connection.cursor() + cursor.execute(sql) + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('issues', '0005_auto_20150623_1923'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='issue', + name='watchers', + ), + ] diff --git a/taiga/projects/issues/migrations/0007_auto_20160614_1201.py b/taiga/projects/issues/migrations/0007_auto_20160614_1201.py new file mode 100644 index 000000000..b0f256e7e --- /dev/null +++ b/taiga/projects/issues/migrations/0007_auto_20160614_1201.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0006_remove_issue_watchers'), + ] + + operations = [ + migrations.AlterField( + model_name='issue', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=False, null=False), blank=True, default=None, null=True, size=None, verbose_name='external reference'), + ), + migrations.AlterField( + model_name='issue', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=list, null=True, size=None, verbose_name='tags'), + ), + ] diff --git a/taiga/projects/issues/migrations/0008_add_due_date.py b/taiga/projects/issues/migrations/0008_add_due_date.py new file mode 100644 index 000000000..cba744431 --- /dev/null +++ b/taiga/projects/issues/migrations/0008_add_due_date.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.2 on 2018-04-09 09:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0007_auto_20160614_1201'), + ] + + operations = [ + migrations.AddField( + model_name='issue', + name='due_date', + field=models.DateField(blank=True, default=None, null=True, verbose_name='due date'), + ), + migrations.AddField( + model_name='issue', + name='due_date_reason', + field=models.TextField(blank=True, default='', verbose_name='reason for the due date'), + ), + ] diff --git a/taiga/projects/issues/migrations/0009_auto_20200615_0811.py b/taiga/projects/issues/migrations/0009_auto_20200615_0811.py new file mode 100644 index 000000000..31b500b94 --- /dev/null +++ b/taiga/projects/issues/migrations/0009_auto_20200615_0811.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-15 08:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0008_add_due_date'), + ] + + operations = [ + migrations.AlterField( + model_name='issue', + name='is_blocked', + field=models.BooleanField(blank=True, default=False, verbose_name='is blocked'), + ), + migrations.AlterField( + model_name='issue', + name='severity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issues', to='projects.Severity', verbose_name='severity'), + ), + migrations.AlterField( + model_name='issue', + name='status', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issues', to='projects.IssueStatus', verbose_name='status'), + ), + migrations.AlterField( + model_name='issue', + name='type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issues', to='projects.IssueType', verbose_name='type'), + ), + ] diff --git a/taiga/projects/issues/migrations/__init__.py b/taiga/projects/issues/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/issues/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/issues/models.py b/taiga/projects/issues/models.py new file mode 100644 index 000000000..a1f5f4e28 --- /dev/null +++ b/taiga/projects/issues/models.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField +from django.conf import settings +from django.utils import timezone +from django.dispatch import receiver +from django.utils.translation import gettext_lazy as _ + +from taiga.projects.due_dates.models import DueDateMixin +from taiga.projects.occ import OCCModelMixin +from taiga.projects.notifications.mixins import WatchedModelMixin +from taiga.projects.mixins.blocked import BlockedMixin +from taiga.projects.tagging.models import TaggedMixin + + +class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, DueDateMixin, models.Model): + ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, + verbose_name=_("ref")) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + default=None, + related_name="owned_issues", + verbose_name=_("owner"), + on_delete=models.SET_NULL, + ) + status = models.ForeignKey( + "projects.IssueStatus", + null=True, + blank=True, + related_name="issues", + verbose_name=_("status"), + on_delete=models.SET_NULL, + ) + severity = models.ForeignKey( + "projects.Severity", + null=True, + blank=True, + related_name="issues", + verbose_name=_("severity"), + on_delete=models.SET_NULL, + ) + priority = models.ForeignKey( + "projects.Priority", + null=True, + blank=True, + related_name="issues", + verbose_name=_("priority"), + on_delete=models.SET_NULL, + ) + type = models.ForeignKey( + "projects.IssueType", + null=True, + blank=True, + related_name="issues", + verbose_name=_("type"), + on_delete=models.SET_NULL, + ) + milestone = models.ForeignKey( + "milestones.Milestone", + null=True, + blank=True, + default=None, + related_name="issues", + verbose_name=_("milestone"), + on_delete=models.SET_NULL, + ) + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="issues", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + finished_date = models.DateTimeField(null=True, blank=True, + verbose_name=_("finished date")) + subject = models.TextField(null=False, blank=False, + verbose_name=_("subject")) + description = models.TextField(null=False, blank=True, verbose_name=_("description")) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=True, + null=True, + default=None, + related_name="issues_assigned_to_me", + verbose_name=_("assigned to"), + on_delete=models.SET_NULL, + ) + attachments = GenericRelation("attachments.Attachment") + external_reference = ArrayField(models.TextField(null=False, blank=False), + null=True, blank=True, default=None, verbose_name=_("external reference")) + _importing = None + + class Meta: + verbose_name = "issue" + verbose_name_plural = "issues" + ordering = ["project", "-id"] + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + if not self.status_id: + self.status = self.project.default_issue_status + + if not self.type_id: + self.type = self.project.default_issue_type + + if not self.severity_id: + self.severity = self.project.default_severity + + if not self.priority_id: + self.priority = self.project.default_priority + + return super().save(*args, **kwargs) + + 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 diff --git a/taiga/projects/issues/permissions.py b/taiga/projects/issues/permissions.py new file mode 100644 index 000000000..9ec83dd1c --- /dev/null +++ b/taiga/projects/issues/permissions.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsAuthenticated, IsSuperUser +from taiga.permissions.permissions import HasProjectPerm, IsProjectAdmin + +from taiga.permissions.permissions import CommentAndOrUpdatePerm + + +class IssuePermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_issues') + create_perms = HasProjectPerm('add_issue') + update_perms = CommentAndOrUpdatePerm('modify_issue', 'comment_issue') + partial_update_perms = CommentAndOrUpdatePerm('modify_issue', 'comment_issue') + destroy_perms = HasProjectPerm('delete_issue') + list_perms = AllowAny() + 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') + watch_perms = IsAuthenticated() & HasProjectPerm('view_issues') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_issues') + promote_to_us_perms = IsAuthenticated() & HasProjectPerm('add_us') + + +class IssueVotersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_issues') + list_perms = HasProjectPerm('view_issues') + + +class IssueWatchersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_issues') + list_perms = HasProjectPerm('view_issues') diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py new file mode 100644 index 000000000..5f959ff0b --- /dev/null +++ b/taiga/projects/issues/serializers.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.base.neighbors import NeighborsSerializerMixin + +from taiga.mdrender.service import render as mdrender +from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.due_dates.serializers import DueDateSerializerMixin +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer +from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin + + +class IssueListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, + OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, ProjectExtraInfoSerializerMixin, + BasicAttachmentsInfoSerializerMixin, DueDateSerializerMixin, + TaggedInProjectResourceSerializer, serializers.LightSerializer): + id = Field() + ref = Field() + severity = Field(attr="severity_id") + priority = Field(attr="priority_id") + type = Field(attr="type_id") + milestone = Field(attr="milestone_id") + project = Field(attr="project_id") + created_date = Field() + modified_date = Field() + finished_date = Field() + subject = Field() + external_reference = Field() + version = Field() + watchers = Field() + is_blocked = Field() + blocked_note = Field() + is_closed = Field() + + +class IssueSerializer(IssueListSerializer): + comment = MethodField() + generated_user_stories = MethodField() + blocked_note_html = MethodField() + description = Field() + description_html = MethodField() + + def get_comment(self, obj): + # NOTE: This method and field is necessary to historical comments work + return "" + + def get_generated_user_stories(self, obj): + assert hasattr(obj, "generated_user_stories_attr"),\ + "instance must have a generated_user_stories_attr attribute" + return obj.generated_user_stories_attr + + def get_blocked_note_html(self, obj): + return mdrender(obj.project, obj.blocked_note) + + def get_description_html(self, obj): + return mdrender(obj.project, obj.description) + + +class IssueNeighborsSerializer(NeighborsSerializerMixin, IssueSerializer): + pass diff --git a/taiga/projects/issues/services.py b/taiga/projects/issues/services.py new file mode 100644 index 000000000..d1220b553 --- /dev/null +++ b/taiga/projects/issues/services.py @@ -0,0 +1,563 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import io +import csv +from collections import OrderedDict +from operator import itemgetter +from contextlib import closing + +from django.db import connection +from django.utils.translation import gettext 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) +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.notifications.utils import attach_watchers_to_queryset + +from . import models + + +##################################################### +# Bulk actions +##################################################### + +def get_issues_from_bulk(bulk_data, **additional_fields): + """Convert `bulk_data` into a list of issues. + + :param bulk_data: List of issues in bulk format. + :param additional_fields: Additional fields when instantiating each issue. + + :return: List of `Issue` instances. + """ + return [models.Issue(subject=line, **additional_fields) + for line in text.split_in_lines(bulk_data)] + + +def create_issues_in_bulk(bulk_data, callback=None, precall=None, **additional_fields): + """Create issues from `bulk_data`. + + :param bulk_data: List of issues in bulk format. + :param callback: Callback to execute after each issue save. + :param additional_fields: Additional fields when instantiating each issue. + + :return: List of created `Issue` instances. + """ + issues = get_issues_from_bulk(bulk_data, **additional_fields) + + disconnect_issues_signals() + + try: + db.save_in_bulk(issues, callback, precall) + finally: + connect_issues_signals() + + 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': }, ...] + """ + 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", + "sprint_estimated_start", "sprint_estimated_finish", "owner", + "owner_full_name", "assigned_to", "assigned_to_full_name", + "status", "severity", "priority", "type", "is_closed", + "attachments", "external_reference", "tags", "watchers", + "voters", "created_date", "modified_date", "finished_date", + "due_date", "due_date_reason"] + + custom_attrs = project.issuecustomattributes.all() + for custom_attr in custom_attrs: + fieldnames.append(custom_attr.name) + + queryset = queryset.prefetch_related("attachments", + "generated_user_stories", + "custom_attributes_values") + queryset = queryset.select_related("owner", + "assigned_to", + "status", + "project") + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + + writer = csv.DictWriter(csv_data, fieldnames=fieldnames) + writer.writeheader() + for issue in queryset: + issue_data = { + "id": issue.id, + "ref": issue.ref, + "subject": issue.subject, + "description": issue.description, + "sprint_id": issue.milestone.id if issue.milestone else None, + "sprint": issue.milestone.name if issue.milestone else None, + "sprint_estimated_start": issue.milestone.estimated_start if issue.milestone else None, + "sprint_estimated_finish": issue.milestone.estimated_finish if issue.milestone else None, + "owner": issue.owner.username if issue.owner else None, + "owner_full_name": issue.owner.get_full_name() if issue.owner else None, + "assigned_to": issue.assigned_to.username if issue.assigned_to else None, + "assigned_to_full_name": issue.assigned_to.get_full_name() if issue.assigned_to else None, + "status": issue.status.name if issue.status else None, + "severity": issue.severity.name, + "priority": issue.priority.name, + "type": issue.type.name, + "is_closed": issue.is_closed, + "attachments": issue.attachments.count(), + "external_reference": issue.external_reference, + "tags": ",".join(issue.tags or []), + "watchers": issue.watchers, + "voters": issue.total_voters, + "created_date": issue.created_date, + "modified_date": issue.modified_date, + "finished_date": issue.finished_date, + "due_date": issue.due_date, + "due_date_reason": issue.due_date_reason, + } + + for custom_attr in custom_attrs: + if not hasattr(issue, "custom_attributes_values"): + continue + value = issue.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) + issue_data[custom_attr.name] = value + + writer.writerow(issue_data) + + return csv_data + + +##################################################### +# Api filter data +##################################################### + +def _get_issues_statuses(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT status_id, count(status_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY status_id + ) + + SELECT "projects_issuestatus"."id", + "projects_issuestatus"."name", + "projects_issuestatus"."color", + "projects_issuestatus"."order", + COALESCE(counters.count, 0) + FROM "projects_issuestatus" + LEFT OUTER JOIN counters ON counters.status_id = projects_issuestatus.id + WHERE "projects_issuestatus"."project_id" = %s + ORDER BY "projects_issuestatus"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, color, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": color, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_issues_types(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT type_id, count(type_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY type_id + ) + + SELECT "projects_issuetype"."id", + "projects_issuetype"."name", + "projects_issuetype"."color", + "projects_issuetype"."order", + COALESCE(counters.count, 0) + FROM "projects_issuetype" + LEFT OUTER JOIN counters ON counters.type_id = projects_issuetype.id + WHERE "projects_issuetype"."project_id" = %s + ORDER BY "projects_issuetype"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, color, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": color, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_issues_priorities(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT priority_id, count(priority_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY priority_id + ) + + SELECT "projects_priority"."id", + "projects_priority"."name", + "projects_priority"."color", + "projects_priority"."order", + COALESCE(counters.count, 0) + FROM "projects_priority" + LEFT OUTER JOIN counters ON counters.priority_id = projects_priority.id + WHERE "projects_priority"."project_id" = %s + ORDER BY "projects_priority"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, color, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": color, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_issues_severities(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT severity_id, count(severity_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY severity_id + ) + + SELECT "projects_severity"."id", + "projects_severity"."name", + "projects_severity"."color", + "projects_severity"."order", + COALESCE(counters.count, 0) + FROM "projects_severity" + LEFT OUTER JOIN counters ON counters.severity_id = projects_severity.id + WHERE "projects_severity"."project_id" = %s + ORDER BY "projects_severity"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, color, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": color, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_issues_assigned_to(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT assigned_to_id, count(assigned_to_id) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} AND "issues_issue"."assigned_to_id" IS NOT NULL + GROUP BY assigned_to_id + ) + + SELECT "projects_membership"."user_id" user_id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."assigned_to_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- unassigned issues + UNION + + SELECT NULL user_id, NULL, NULL, count(coalesce(assigned_to_id, -1)) count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} AND "issues_issue"."assigned_to_id" IS NULL + GROUP BY assigned_to_id + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id] + where_params) + rows = cursor.fetchall() + + result = [] + none_valued_added = False + for id, full_name, username, count in rows: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + + if id is None: + none_valued_added = True + + # If there was no issue with null assigned_to we manually add it + if not none_valued_added: + result.append({ + "id": None, + "full_name": "", + "count": 0, + }) + + return sorted(result, key=itemgetter("full_name")) + + +def _get_issues_owners(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT "issues_issue"."owner_id" owner_id, count("issues_issue"."owner_id") count + FROM "issues_issue" + INNER JOIN "projects_project" ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY "issues_issue"."owner_id" + ) + + SELECT "projects_membership"."user_id" id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- System users + UNION + + SELECT "users_user"."id" user_id, + "users_user"."full_name" full_name, + "users_user"."username", + COALESCE("counters".count, 0) count + FROM users_user + LEFT OUTER JOIN counters ON ("users_user"."id" = "counters"."owner_id") + WHERE ("users_user"."is_system" IS TRUE) + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, full_name, username, count in rows: + if count > 0: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + return sorted(result, key=itemgetter("full_name")) + + +def _get_issues_roles(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH "issue_counters" AS ( + SELECT DISTINCT "issues_issue"."status_id" "status_id", + "issues_issue"."id" "issue_id", + "projects_membership"."role_id" "role_id" + FROM "issues_issue" + INNER JOIN "projects_project" + ON ("issues_issue"."project_id" = "projects_project"."id") + LEFT OUTER JOIN "projects_membership" + ON "projects_membership"."user_id" = "issues_issue"."assigned_to_id" + WHERE {where} + ), + "counters" AS ( + SELECT "role_id" as "role_id", + COUNT("role_id") "count" + FROM "issue_counters" + GROUP BY "role_id" + ) + + SELECT "users_role"."id", + "users_role"."name", + "users_role"."order", + COALESCE("counters"."count", 0) + FROM "users_role" + LEFT OUTER JOIN "counters" + ON "counters"."role_id" = "users_role"."id" + WHERE "users_role"."project_id" = %s + ORDER BY "users_role"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": None, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + +def _get_issues_tags(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH "issues_tags" AS ( + SELECT "tag", + COUNT("tag") "counter" + FROM ( + SELECT UNNEST("issues_issue"."tags") "tag" + FROM "issues_issue" + INNER JOIN "projects_project" + ON ("issues_issue"."project_id" = "projects_project"."id") + WHERE {where} + ) "tags" + GROUP BY "tag"), + "project_tags" AS ( + SELECT reduce_dim("tags_colors") "tag_color" + FROM "projects_project" + WHERE "id"=%s) + + SELECT "tag_color"[1] "tag", + "tag_color"[2] "color", + COALESCE("issues_tags"."counter", 0) "counter" + FROM project_tags + LEFT JOIN "issues_tags" ON "project_tags"."tag_color"[1] = "issues_tags"."tag" + ORDER BY "tag" + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for name, color, count in rows: + result.append({ + "name": name, + "color": color, + "count": count, + }) + return sorted(result, key=itemgetter("name")) + + +def get_issues_filters_data(project, querysets): + """ + Given a project and an issues queryset, return a simple data structure + of all possible filters for the issues in the queryset. + """ + data = OrderedDict([ + ("types", _get_issues_types(project, querysets["types"])), + ("statuses", _get_issues_statuses(project, querysets["statuses"])), + ("priorities", _get_issues_priorities(project, querysets["priorities"])), + ("severities", _get_issues_severities(project, querysets["severities"])), + ("assigned_to", _get_issues_assigned_to(project, querysets["assigned_to"])), + ("owners", _get_issues_owners(project, querysets["owners"])), + ("tags", _get_issues_tags(project, querysets["tags"])), + ("roles", _get_issues_roles(project, querysets["roles"])), + ]) + + return data diff --git a/taiga/projects/issues/signals.py b/taiga/projects/issues/signals.py new file mode 100644 index 000000000..0a86e61f6 --- /dev/null +++ b/taiga/projects/issues/signals.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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 +#################################### + +def set_finished_date_when_edit_issue(sender, instance, **kwargs): + if instance.status is None: + return + if instance.status.is_closed and not instance.finished_date: + 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) diff --git a/taiga/projects/issues/utils.py b/taiga/projects/issues/utils.py new file mode 100644 index 000000000..7a9109c96 --- /dev/null +++ b/taiga/projects/issues/utils.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.projects.attachments.utils import attach_basic_attachments +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset + + +def attach_generated_user_stories(queryset, as_field="generated_user_stories_attr"): + """Attach generated user stories json column to each object of the queryset. + + :param queryset: A Django issues queryset object. + :param as_field: Attach the generated user stories as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(t)) + FROM( + SELECT + userstories_userstory.id, + userstories_userstory.ref, + userstories_userstory.subject + FROM userstories_userstory + WHERE generated_from_issue_id = {tbl}.id) t""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False): + if include_attachments: + queryset = attach_basic_attachments(queryset) + queryset = queryset.extra(select={"include_attachments": "True"}) + + queryset = attach_generated_user_stories(queryset) + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/issues/validators.py b/taiga/projects/issues/validators.py new file mode 100644 index 000000000..6734b9af0 --- /dev/null +++ b/taiga/projects/issues/validators.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.utils.translation import gettext 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 +from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.validators import ProjectExistsValidator + +from . import models + + +class IssueValidator(AssignedToValidator, WatchersValidator, EditableWatchedResourceSerializer, + validators.ModelValidator): + + tags = TagsAndTagsColorsField(default=[], required=False) + external_reference = PgArrayField(required=False) + + class Meta: + model = models.Issue + read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') + + +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 diff --git a/taiga/projects/likes/__init__.py b/taiga/projects/likes/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/likes/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/likes/admin.py b/taiga/projects/likes/admin.py new file mode 100644 index 000000000..a01da3689 --- /dev/null +++ b/taiga/projects/likes/admin.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin +from django.contrib.contenttypes.admin import GenericTabularInline + +from . import models + + +class LikeInline(GenericTabularInline): + model = models.Like + extra = 0 + raw_id_fields = ["user"] diff --git a/taiga/projects/likes/migrations/0001_initial.py b/taiga/projects/likes/migrations/0001_initial.py new file mode 100644 index 000000000..676a899ad --- /dev/null +++ b/taiga/projects/likes/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Like', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('created_date', models.DateTimeField(verbose_name='created date', auto_now_add=True)), + ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), + ('user', models.ForeignKey(related_name='likes', verbose_name='user', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ], + options={ + 'verbose_name': 'Like', + 'verbose_name_plural': 'Likes', + }, + ), + migrations.CreateModel( + name='Likes', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('count', models.PositiveIntegerField(default=0, verbose_name='count')), + ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name': 'Likes', + 'verbose_name_plural': 'Likes', + }, + ), + migrations.AlterUniqueTogether( + name='likes', + unique_together=set([('content_type', 'object_id')]), + ), + migrations.AlterUniqueTogether( + name='like', + unique_together=set([('content_type', 'object_id', 'user')]), + ), + ] diff --git a/taiga/projects/likes/migrations/0002_auto_20151130_2230.py b/taiga/projects/likes/migrations/0002_auto_20151130_2230.py new file mode 100644 index 000000000..1fd30f0ed --- /dev/null +++ b/taiga/projects/likes/migrations/0002_auto_20151130_2230.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('likes', '0001_initial'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='likes', + unique_together=set([]), + ), + migrations.RemoveField( + model_name='likes', + name='content_type', + ), + migrations.DeleteModel( + name='Likes', + ), + ] diff --git a/taiga/projects/likes/migrations/__init__.py b/taiga/projects/likes/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/likes/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/likes/mixins/__init__.py b/taiga/projects/likes/mixins/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/likes/mixins/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/likes/mixins/viewsets.py b/taiga/projects/likes/mixins/viewsets.py new file mode 100644 index 000000000..4e63b259b --- /dev/null +++ b/taiga/projects/likes/mixins/viewsets.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.exceptions import ObjectDoesNotExist + +from taiga.base import response +from taiga.base.api import viewsets +from taiga.base.api.utils import get_object_or_error +from taiga.base.decorators import detail_route + +from taiga.projects.likes import serializers +from taiga.projects.likes import services + + +class LikedResourceMixin: + """ + NOTE:the classes using this mixing must have a method: + def pre_conditions_on_save(self, obj) + """ + @detail_route(methods=["POST"]) + def like(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "like", obj) + self.pre_conditions_on_save(obj) + + services.add_like(obj, user=request.user) + return response.Ok() + + @detail_route(methods=["POST"]) + def unlike(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "unlike", obj) + self.pre_conditions_on_save(obj) + + services.remove_like(obj, user=request.user) + return response.Ok() + + +class FansViewSetMixin: + # Is a ModelListViewSet with two required params: permission_classes and resource_model + serializer_class = serializers.FanSerializer + list_serializer_class = serializers.FanSerializer + permission_classes = None + resource_model = None + + def retrieve(self, request, *args, **kwargs): + pk = kwargs.get("pk", None) + resource_id = kwargs.get("resource_id", None) + resource = get_object_or_error(self.resource_model, request.user, pk=resource_id) + + self.check_permissions(request, 'retrieve', resource) + + try: + self.object = services.get_fans(resource).get(pk=pk) + except ObjectDoesNotExist: # or User.DoesNotExist + return response.NotFound() + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + def list(self, request, *args, **kwargs): + resource_id = kwargs.get("resource_id", None) + resource = get_object_or_error(self.resource_model, request.user, pk=resource_id) + + self.check_permissions(request, 'list', resource) + + return super().list(request, *args, **kwargs) + + def get_queryset(self): + resource = self.resource_model.objects.get(pk=self.kwargs.get("resource_id")) + return services.get_fans(resource) diff --git a/taiga/projects/likes/models.py b/taiga/projects/likes/models.py new file mode 100644 index 000000000..ca91a077e --- /dev/null +++ b/taiga/projects/likes/models.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Like(models.Model): + content_type = models.ForeignKey("contenttypes.ContentType", on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=False, + blank=False, + related_name="likes", + verbose_name=_("user"), + on_delete=models.CASCADE, + ) + created_date = models.DateTimeField(null=False, blank=False, auto_now_add=True, + verbose_name=_("created date")) + + class Meta: + verbose_name = _("Like") + verbose_name_plural = _("Likes") + unique_together = ("content_type", "object_id", "user") + + @property + def project(self): + if hasattr(self.content_object, 'project'): + return self.content_object.project + return None + + def __str__(self): + return self.user.get_full_name() diff --git a/taiga/projects/likes/serializers.py b/taiga/projects/likes/serializers.py new file mode 100644 index 000000000..2eaad0bd8 --- /dev/null +++ b/taiga/projects/likes/serializers.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField + + +class FanSerializer(serializers.LightSerializer): + id = Field() + username = Field() + full_name = MethodField() + + def get_full_name(self, obj): + return obj.get_full_name() diff --git a/taiga/projects/likes/services.py b/taiga/projects/likes/services.py new file mode 100644 index 000000000..12b5daa20 --- /dev/null +++ b/taiga/projects/likes/services.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import F +from django.db.transaction import atomic +from django.apps import apps +from django.contrib.auth import get_user_model + +from .models import Like + + +def add_like(obj, user): + """Add a like to an object. + + If the user has already liked the object nothing happends, so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User adding the like. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with atomic(): + like, created = Like.objects.get_or_create(content_type=obj_type, object_id=obj.id, user=user) + if like.project is not None: + like.project.refresh_totals() + + return like + + +def remove_like(obj, user): + """Remove an user like from an object. + + If the user has not liked the object nothing happens so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User removing her like. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with atomic(): + qs = Like.objects.filter(content_type=obj_type, object_id=obj.id, user=user) + if not qs.exists(): + return + + like = qs.first() + project = like.project + qs.delete() + + if project is not None: + project.refresh_totals() + + +def get_fans(obj): + """Get the fans of an object. + + :param obj: Any Django model instance. + + :return: User queryset object representing the users that liked the object. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + return get_user_model().objects.filter(likes__content_type=obj_type, likes__object_id=obj.id) + + +def get_liked(user_or_id, model): + """Get the objects liked by an user. + + :param user_or_id: :class:`~taiga.users.models.User` instance or id. + :param model: Show only objects of this kind. Can be any Django model class. + + :return: Queryset of objects representing the likes of the user. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + conditions = ('likes_like.content_type_id = %s', + '%s.id = likes_like.object_id' % model._meta.db_table, + 'likes_like.user_id = %s') + + if isinstance(user_or_id, get_user_model()): + user_id = user_or_id.id + else: + user_id = user_or_id + + return model.objects.extra(where=conditions, tables=('likes_like',), + params=(obj_type.id, user_id)) diff --git a/taiga/projects/management/__init__.py b/taiga/projects/management/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/management/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/management/commands/__init__.py b/taiga/projects/management/commands/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/management/commands/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/management/commands/block_user_projects.py b/taiga/projects/management/commands/block_user_projects.py new file mode 100644 index 000000000..903a32ce7 --- /dev/null +++ b/taiga/projects/management/commands/block_user_projects.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from taiga.projects.choices import BLOCKED_BY_NONPAYMENT +from taiga.projects.models import Project + + +class Command(BaseCommand): + help = "Block user projects" + + def add_arguments(self, parser): + parser.add_argument("owner_usernames", + nargs="+", + help="") + + parser.add_argument("--is-private", + dest="is_private") + + parser.add_argument("--blocked-code", + dest="blocked_code") + + def handle(self, *args, **options): + owner_usernames = options["owner_usernames"] + projects = Project.objects.filter(owner__username__in=owner_usernames) + + is_private = options.get("is_private") + if is_private is not None: + is_private = is_private.lower() + is_private = is_private[0] in ["t", "y", "1"] + projects = projects.filter(is_private=is_private) + + blocked_code = options.get("blocked_code") + blocked_code = blocked_code if blocked_code is not None else BLOCKED_BY_NONPAYMENT + projects.update(blocked_code=blocked_code) diff --git a/taiga/projects/management/commands/change_project_slug.py b/taiga/projects/management/commands/change_project_slug.py new file mode 100644 index 000000000..27543762f --- /dev/null +++ b/taiga/projects/management/commands/change_project_slug.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from django.core.management.base import CommandError +from django.test.utils import override_settings + +from taiga.base.utils.slug import slugify_uniquely +from taiga.projects.models import Project +from taiga.projects.history.models import HistoryEntry +from taiga.timeline.rebuilder import rebuild_timeline + + +class Command(BaseCommand): + help = 'Change the project slug from a new one' + + def add_arguments(self, parser): + parser.add_argument('current_slug', help="The current project slug") + parser.add_argument('new_slug', help="The new project slug") + + @override_settings(DEBUG=False) + def handle(self, *args, **options): + current_slug = options["current_slug"] + new_slug = options["new_slug"] + + try: + project = Project.objects.get(slug=current_slug) + except Project.DoesNotExist: + raise CommandError("There is no project with the slug '{}'".format(current_slug)) + + slug = slugify_uniquely(new_slug, Project) + if slug != new_slug: + raise CommandError("Invalid new slug, maybe you can try with '{}'".format(slug)) + + # Change slug + self.stdout.write(self.style.SUCCESS("-> Change slug to '{}'.".format(slug))) + project.slug = slug + project.save() + + # Reset diff cache in history entries + self.stdout.write(self.style.SUCCESS("-> Reset value_diff cache for history entries.")) + HistoryEntry.objects.filter(project=project).update(values_diff_cache=None) + + # Regenerate timeline + self.stdout.write(self.style.SUCCESS("-> Regenerate timeline entries.")) + rebuild_timeline(None, None, project.id) diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py new file mode 100644 index 000000000..66135eaf3 --- /dev/null +++ b/taiga/projects/management/commands/sample_data.py @@ -0,0 +1,648 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime +from os import path +from hashlib import sha1 + + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils.timezone import now +from django.conf import settings +from django.contrib.contenttypes.models import ContentType + +from sampledatahelper.helper import SampleDataHelper + +from taiga.users.models import * +from taiga.permissions.choices import ANON_PERMISSIONS +from taiga.projects.choices import BLOCKED_BY_STAFF +from taiga.external_apps.models import Application, ApplicationToken +from taiga.projects.models import * +from taiga.projects.epics.models import * +from taiga.projects.milestones.models import * +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.services.stats import get_stats_for_project +from taiga.projects.userstories.models import * +from taiga.projects.tasks.models import * +from taiga.projects.issues.models import * +from taiga.projects.wiki.models import * +from taiga.projects.attachments.models import * +from taiga.projects.custom_attributes.models import * +from taiga.projects.custom_attributes.choices import TYPES_CHOICES, TEXT_TYPE, MULTILINE_TYPE, DATE_TYPE, URL_TYPE +from taiga.projects.history.services import take_snapshot +from taiga.projects.likes.services import add_like +from taiga.projects.votes.services import add_vote +from taiga.events.apps import disconnect_events_signals +from taiga.projects.services.stats import get_stats_for_project + + +ATTACHMENT_SAMPLE_DATA = [ + path.join(settings.BASE_DIR, "taiga/projects/management/commands/sample_data"), + [".txt", ] +] + +COLOR_CHOICES = [ + "#FC8EAC", + "#A5694F", + "#002e33", + "#67CF00", + "#71A6D2", + "#FFF8E7", + "#4B0082", + "#007000", + "#40826D", + "#708090", + "#761CEC", + "#0F0F0F", + "#D70A53", + "#CC0000", + "#FFCC00", + "#FFFF00", + "#C0FF33", + "#B6DA55", + "#2099DB"] + + +SUBJECT_CHOICES = [ + "Create the user model", + "Implement the form", + "Create the html template", + "Fixing templates for Django 1.6.", + "get_actions() does not check for 'delete_selected' in actions", + "Experimental: modular file types", + "Add setting to allow regular users to create folders at the root level.", + "Add tests for bulk operations", + "Create testsuite with matrix builds", + "Lighttpd support", + "Lighttpd x-sendfile support", + "Added file copying and processing of images (resizing)", + "Exception is thrown if trying to add a folder with existing name", + "Feature/improved image admin", + "Support for bulk actions", + "Migrate to Python 3 and milk a beautiful cow"] + +URL_CHOICES = [ + "https://taiga.io", + "https://blog.taiga.io", + "https://tree.taiga.io", + "https://tribe.taiga.io"] + +WIP_LIMITS_CHOICES = ( + [0] * 14 + + [2] * 1 + + [3] * 2 + + [4] * 3) + +BASE_USERS = getattr(settings, "SAMPLE_DATA_BASE_USERS", {}) +NUM_USERS = getattr(settings, "SAMPLE_DATA_NUM_USERS", 10) +NUM_INVITATIONS =getattr(settings, "SAMPLE_DATA_NUM_INVITATIONS", 2) +NUM_PROJECTS =getattr(settings, "SAMPLE_DATA_NUM_PROJECTS", 4) +NUM_EMPTY_PROJECTS = getattr(settings, "SAMPLE_DATA_NUM_EMPTY_PROJECTS", 2) +NUM_BLOCKED_PROJECTS = getattr(settings, "SAMPLE_DATA_NUM_BLOCKED_PROJECTS", 1) +NUM_MILESTONES = getattr(settings, "SAMPLE_DATA_NUM_MILESTONES", (1, 5)) +NUM_EPICS = getattr(settings, "SAMPLE_DATA_NUM_EPICS", (4, 8)) +NUM_USS_EPICS = getattr(settings, "SAMPLE_DATA_NUM_USS_EPICS", (2, 12)) +NUM_USS = getattr(settings, "SAMPLE_DATA_NUM_USS", (3, 7)) +NUM_TASKS_FINISHED = getattr(settings, "SAMPLE_DATA_NUM_TASKS_FINISHED", (1, 5)) +NUM_TASKS = getattr(settings, "SAMPLE_DATA_NUM_TASKS", (0, 4)) +NUM_USS_BACK = getattr(settings, "SAMPLE_DATA_NUM_USS_BACK", (8, 20)) +NUM_ISSUES = getattr(settings, "SAMPLE_DATA_NUM_ISSUES", (12, 25)) +NUM_WIKI_LINKS = getattr(settings, "SAMPLE_DATA_NUM_WIKI_LINKS", (0, 15)) +NUM_ATTACHMENTS = getattr(settings, "SAMPLE_DATA_NUM_ATTACHMENTS", (1, 4)) +NUM_LIKES = getattr(settings, "SAMPLE_DATA_NUM_LIKES", (0, 10)) +NUM_VOTES = getattr(settings, "SAMPLE_DATA_NUM_VOTES", (0, 10)) +NUM_WATCHERS = getattr(settings, "SAMPLE_DATA_NUM_PROJECT_WATCHERS", (0, 8)) +NUM_APPLICATIONS = getattr(settings, "SAMPLE_DATA_NUM_APPLICATIONS", (1, 3)) +NUM_APPLICATIONS_TOKENS = getattr(settings, "SAMPLE_DATA_NUM_APPLICATIONS_TOKENS", (1, 3)) +FEATURED_PROJECTS_POSITIONS = [0, 1, 2] +LOOKING_FOR_PEOPLE_PROJECTS_POSITIONS = [0, 1, 2] + + +class Command(BaseCommand): + sd = SampleDataHelper(seed=12345678901) + + #@transaction.atomic + def handle(self, *args, **options): + # Prevent events emission when sample data is running + disconnect_events_signals() + + self.users = [User.objects.get(is_superuser=True)] + + # create users + if BASE_USERS: + for username, full_name, email in BASE_USERS: + self.users.append(self.create_user(username=username, full_name=full_name, email=email)) + else: + for x in range(NUM_USERS): + self.users.append(self.create_user(counter=x)) + + # create project + projects_range = range(NUM_PROJECTS + NUM_EMPTY_PROJECTS + NUM_BLOCKED_PROJECTS) + empty_projects_range = range(NUM_PROJECTS, NUM_PROJECTS + NUM_EMPTY_PROJECTS ) + blocked_projects_range = range( + NUM_PROJECTS + NUM_EMPTY_PROJECTS, + NUM_PROJECTS + NUM_EMPTY_PROJECTS + NUM_BLOCKED_PROJECTS + ) + + for x in projects_range: + project = self.create_project( + x + 1, # this way the Project will have the same name as the id: Project 1 with id: 1 + is_private=x in [2, 4], + blocked_code = BLOCKED_BY_STAFF if x in(blocked_projects_range) else None + ) + + # added memberships + computable_project_roles = set() + for user in self.users: + if user == project.owner: + continue + + role = self.sd.db_object_from_queryset(project.roles.all()) + + Membership.objects.create(email=user.email, + project=project, + role=role, + is_admin=self.sd.boolean(), + user=user) + + if role.computable: + computable_project_roles.add(role) + + # Delete a random member so all the projects doesn't have the same team + Membership.objects.filter(project=project).exclude(user=project.owner).order_by("?").first().delete() + + # added invitations + for i in range(NUM_INVITATIONS): + role = self.sd.db_object_from_queryset(project.roles.all()) + + Membership.objects.create(email=self.sd.email(), + project=project, + role=role, + is_admin=self.sd.boolean(), + token=self.sd.hex_chars(10,10)) + + if role.computable: + computable_project_roles.add(role) + + # If the project isn't empty + if x not in empty_projects_range: + # added custom attributes + names = set([self.sd.words(1, 3) for i in range(1, 6)]) + for name in names: + EpicCustomAttribute.objects.create(name=name, + description=self.sd.words(3, 12), + type=self.sd.choice(TYPES_CHOICES)[0], + project=project, + order=i) + names = set([self.sd.words(1, 3) for i in range(1, 6)]) + for name in names: + UserStoryCustomAttribute.objects.create(name=name, + description=self.sd.words(3, 12), + type=self.sd.choice(TYPES_CHOICES)[0], + project=project, + order=i) + names = set([self.sd.words(1, 3) for i in range(1, 6)]) + for name in names: + TaskCustomAttribute.objects.create(name=name, + description=self.sd.words(3, 12), + type=self.sd.choice(TYPES_CHOICES)[0], + project=project, + order=i) + names = set([self.sd.words(1, 3) for i in range(1, 6)]) + for name in names: + IssueCustomAttribute.objects.create(name=name, + description=self.sd.words(3, 12), + type=self.sd.choice(TYPES_CHOICES)[0], + project=project, + order=i) + + # Create swimlanes + if self.sd.boolean(): + names = set([self.sd.words(1, 2) for i in range(1, 6)]) + for j, name in enumerate(names): + swimlane = Swimlane.objects.create(name=name, + project=project, + order=j+1) + # Set wip limits + for status in swimlane.statuses.all(): + status.wip_limit = self.sd.choice(WIP_LIMITS_CHOICES) + status.save() + + start_date = now() - datetime.timedelta(55) + + # create milestones + for y in range(self.sd.int(*NUM_MILESTONES)): + end_date = start_date + datetime.timedelta(15) + milestone = self.create_milestone(project, start_date, end_date) + + # create uss asociated to milestones + for z in range(self.sd.int(*NUM_USS)): + us = self.create_us(project, milestone, computable_project_roles) + + # create tasks + rang = NUM_TASKS_FINISHED if start_date <= now() and end_date <= now() else NUM_TASKS + for w in range(self.sd.int(*rang)): + if start_date <= now() and end_date <= now(): + task = self.create_task(project, milestone, us, start_date, + end_date, closed=True) + elif start_date <= now() and end_date >= now(): + task = self.create_task(project, milestone, us, start_date, + now()) + else: + # No task on not initiated milestones + pass + + start_date = end_date + + # created unassociated uss. + for y in range(self.sd.int(*NUM_USS_BACK)): + us = self.create_us(project, None, computable_project_roles) + + # create bugs. + for y in range(self.sd.int(*NUM_ISSUES)): + bug = self.create_bug(project) + + # create a wiki pages and wiki links + wiki_page = self.create_wiki_page(project, "home") + + for y in range(self.sd.int(*NUM_WIKI_LINKS)): + wiki_link = self.create_wiki_link(project) + if self.sd.boolean(): + self.create_wiki_page(project, wiki_link.href) + + # create epics + for y in range(self.sd.int(*NUM_EPICS)): + epic = self.create_epic(project) + + project.refresh_from_db() + + # Set color for some tags: + for tag in project.tags_colors: + if self.sd.boolean(): + tag[1] = self.generate_color(tag[0]) + + # Set a value to total_story_points to show the deadline in the backlog + project_stats = get_stats_for_project(project) + defined_points = project_stats["defined_points"] + project.total_story_points = int(defined_points * self.sd.int(5,12) / 10) + project.save() + + self.create_likes(project) + + + def create_attachment(self, obj, order): + attached_file = self.sd.file_from_directory(*ATTACHMENT_SAMPLE_DATA) + membership = self.sd.db_object_from_queryset(obj.project.memberships + .filter(user__isnull=False)) + attachment = Attachment.objects.create(project=obj.project, + name=path.basename(attached_file.name), + size=attached_file.size, + content_object=obj, + order=order, + owner=membership.user, + is_deprecated=self.sd.boolean(), + description=self.sd.words(3, 12), + attached_file=attached_file) + return attachment + + + def create_wiki_link(self, project, title=None): + wiki_link = WikiLink.objects.create(project=project, + title=title or self.sd.words(1, 3)) + return wiki_link + + + def create_wiki_page(self, project, slug): + wiki_page = WikiPage.objects.create(project=project, + slug=slug, + content=self.sd.paragraphs(3,15), + owner=self.sd.db_object_from_queryset( + project.memberships.filter(user__isnull=False)).user) + + for i in range(self.sd.int(*NUM_ATTACHMENTS)): + attachment = self.create_attachment(wiki_page, i+1) + + take_snapshot(wiki_page, + user=wiki_page.owner) + + # Add history entry + wiki_page.content=self.sd.paragraphs(3,15) + wiki_page.save() + take_snapshot(wiki_page, + comment=self.sd.paragraph(), + user=wiki_page.owner) + + return wiki_page + + def get_custom_attributes_value(self, type): + if type == TEXT_TYPE: + return self.sd.words(1, 12) + if type == MULTILINE_TYPE: + return self.sd.paragraphs(2, 4) + if type == DATE_TYPE: + return self.sd.future_date(min_distance=0, max_distance=365) + if type == URL_TYPE: + return self.sd.choice(URL_CHOICES) + return None + + def create_bug(self, project): + bug = Issue.objects.create(project=project, + subject=self.sd.choice(SUBJECT_CHOICES), + description=self.sd.paragraph(), + owner=self.sd.db_object_from_queryset( + project.memberships.filter(user__isnull=False)).user, + severity=self.sd.db_object_from_queryset(Severity.objects.filter( + project=project)), + status=self.sd.db_object_from_queryset(IssueStatus.objects.filter( + project=project)), + priority=self.sd.db_object_from_queryset(Priority.objects.filter( + project=project)), + type=self.sd.db_object_from_queryset(IssueType.objects.filter( + project=project)), + tags=self.sd.words(1, 10).split(" ")) + + bug.save() + + custom_attributes_values = {str(ca.id): self.get_custom_attributes_value(ca.type) for ca + in project.issuecustomattributes.all().order_by('id') if self.sd.boolean()} + if custom_attributes_values: + bug.custom_attributes_values.attributes_values = custom_attributes_values + bug.custom_attributes_values.save() + + for i in range(self.sd.int(*NUM_ATTACHMENTS)): + attachment = self.create_attachment(bug, i+1) + + if bug.status.order != 1: + bug.assigned_to = self.sd.db_object_from_queryset(project.memberships.filter( + user__isnull=False)).user + bug.save() + + take_snapshot(bug, + user=bug.owner) + + # Add history entry + bug.status=self.sd.db_object_from_queryset(IssueStatus.objects.filter(project=project)) + bug.save() + take_snapshot(bug, + comment=self.sd.paragraph(), + user=bug.owner) + + self.create_votes(bug) + self.create_watchers(bug) + + return bug + + def create_task(self, project, milestone, us, min_date, max_date, closed=False): + task = Task(subject=self.sd.choice(SUBJECT_CHOICES), + description=self.sd.paragraph(), + project=project, + owner=self.sd.db_object_from_queryset(project.memberships.filter(user__isnull=False)).user, + milestone=milestone, + user_story=us, + finished_date=None, + assigned_to = self.sd.db_object_from_queryset( + project.memberships.filter(user__isnull=False)).user, + tags=self.sd.words(1, 10).split(" ")) + + if closed: + task.status = project.task_statuses.get(order=4) + else: + task.status = self.sd.db_object_from_queryset(project.task_statuses.all()) + + if task.status.is_closed: + task.finished_date = self.sd.datetime_between(min_date, max_date) + + task.save() + + custom_attributes_values = {str(ca.id): self.get_custom_attributes_value(ca.type) for ca + in project.taskcustomattributes.all().order_by('id') if self.sd.boolean()} + if custom_attributes_values: + task.custom_attributes_values.attributes_values = custom_attributes_values + task.custom_attributes_values.save() + + for i in range(self.sd.int(*NUM_ATTACHMENTS)): + attachment = self.create_attachment(task, i+1) + + take_snapshot(task, + user=task.owner) + + # Add history entry + task.status=self.sd.db_object_from_queryset(project.task_statuses.all()) + task.save() + take_snapshot(task, + comment=self.sd.paragraph(), + user=task.owner) + + self.create_votes(task) + self.create_watchers(task) + + return task + + def create_us(self, project, milestone=None, computable_project_roles=[]): + us = UserStory.objects.create(subject=self.sd.choice(SUBJECT_CHOICES), + project=project, + owner=self.sd.db_object_from_queryset( + project.memberships.filter(user__isnull=False)).user, + description=self.sd.paragraph(), + milestone=milestone, + status=self.sd.db_object_from_queryset(project.us_statuses.filter( + is_closed=False)), + tags=self.sd.words(1, 3).split(" "), + swimlane=self.sd.choice(list(project.swimlanes.all())) if project.swimlanes.count() > 0 else None) + + for role_points in us.role_points.filter(role__in=computable_project_roles): + if milestone: + role_points.points = self.sd.db_object_from_queryset( + us.project.points.exclude(value=None)) + else: + role_points.points = self.sd.db_object_from_queryset( + us.project.points.all()) + + role_points.save() + + us.save() + + custom_attributes_values = {str(ca.id): self.get_custom_attributes_value(ca.type) for ca + in project.userstorycustomattributes.all().order_by('id') if self.sd.boolean()} + if custom_attributes_values: + us.custom_attributes_values.attributes_values = custom_attributes_values + us.custom_attributes_values.save() + + + for i in range(self.sd.int(*NUM_ATTACHMENTS)): + attachment = self.create_attachment(us, i+1) + + if self.sd.choice([True, True, False, True, True]): + us.assigned_to = self.sd.db_object_from_queryset(project.memberships.filter( + user__isnull=False)).user + us.save() + + + take_snapshot(us, + user=us.owner) + + # Add history entry + us.status=self.sd.db_object_from_queryset(project.us_statuses.filter(is_closed=False)) + us.save() + take_snapshot(us, + comment=self.sd.paragraph(), + user=us.owner) + + self.create_votes(us) + self.create_watchers(us) + + return us + + def create_milestone(self, project, start_date, end_date): + milestone = Milestone.objects.create(project=project, + name='Sprint {0}-{1}-{2}'.format(start_date.year, + start_date.month, + start_date.day), + owner=self.sd.db_object_from_queryset( + project.memberships.filter(user__isnull=False)).user, + created_date=start_date, + modified_date=start_date, + estimated_start=start_date, + estimated_finish=end_date, + order=10) + take_snapshot(milestone, user=milestone.owner) + + return milestone + + def create_epic(self, project): + epic = Epic.objects.create(subject=self.sd.choice(SUBJECT_CHOICES), + project=project, + owner=self.sd.db_object_from_queryset( + project.memberships.filter(user__isnull=False)).user, + description=self.sd.paragraph(), + status=self.sd.db_object_from_queryset(project.epic_statuses.filter( + is_closed=False)), + tags=self.sd.words(1, 3).split(" ")) + epic.save() + + custom_attributes_values = {str(ca.id): self.get_custom_attributes_value(ca.type) for ca + in project.epiccustomattributes.all().order_by("id") if self.sd.boolean()} + if custom_attributes_values: + epic.custom_attributes_values.attributes_values = custom_attributes_values + epic.custom_attributes_values.save() + + for i in range(self.sd.int(*NUM_ATTACHMENTS)): + attachment = self.create_attachment(epic, i+1) + + if self.sd.choice([True, True, False, True, True]): + epic.assigned_to = self.sd.db_object_from_queryset(project.memberships.filter( + user__isnull=False)).user + epic.save() + + take_snapshot(epic, + user=epic.owner) + + # Add history entry + epic.status=self.sd.db_object_from_queryset(project.epic_statuses.filter(is_closed=False)) + epic.save() + take_snapshot(epic, + comment=self.sd.paragraph(), + user=epic.owner) + + self.create_votes(epic) + self.create_watchers(epic) + + if self.sd.choice([True, True, False, True, True]): + filters = {} + if self.sd.choice([True, True, False, True, True]): + filters = {"project": epic.project} + n = self.sd.choice(list(range(self.sd.int(*NUM_USS_EPICS)))) + user_stories = UserStory.objects.filter(**filters).order_by("?")[:n] + for idx, us in enumerate(list(user_stories)): + RelatedUserStory.objects.create(epic=epic, + user_story=us, + order=idx+1) + + # Add history entry + take_snapshot(epic, + user=epic.owner) + + # Add history entry + epic.status=self.sd.db_object_from_queryset(project.epic_statuses.filter(is_closed=False)) + epic.save() + take_snapshot(epic, + comment=self.sd.paragraph(), + user=epic.owner) + + return epic + + def create_project(self, counter, is_private=None, blocked_code=None): + if is_private is None: + is_private=self.sd.boolean() + + anon_permissions = not is_private and list(map(lambda perm: perm[0], ANON_PERMISSIONS)) or [] + public_permissions = not is_private and list(map(lambda perm: perm[0], ANON_PERMISSIONS)) or [] + project = Project.objects.create(slug='project-%s'%(counter), + name='Project Example {0}'.format(counter), + description='Project example {0} description'.format(counter), + owner=self.sd.choice(self.users), + is_private=is_private, + anon_permissions=anon_permissions, + public_permissions=public_permissions, + total_story_points=self.sd.int(600, 3000), + total_milestones=self.sd.int(5,10), + tags=self.sd.words(1, 10).split(" "), + blocked_code=blocked_code) + + project.is_looking_for_people = counter in LOOKING_FOR_PEOPLE_PROJECTS_POSITIONS + if project.is_looking_for_people: + project.looking_for_people_note = self.sd.short_sentence() + project.is_featured = counter in FEATURED_PROJECTS_POSITIONS + project.is_kanban_activated = True + project.is_epics_activated = True + project.save() + take_snapshot(project, user=project.owner) + + self.create_likes(project) + self.create_watchers(project, NotifyLevel.involved) + + return project + + def create_user(self, counter=None, username=None, full_name=None, email=None): + counter = counter or self.sd.int() + username = username or "user{0}".format(counter) + full_name = full_name or "{} {}".format(self.sd.name('es'), self.sd.surname('es', number=1)) + email = email or "user{0}@taigaio.demo".format(counter) + + user = User.objects.create(username=username, + full_name=full_name, + email=email, + token=self.sd.hex_chars(10,10), + color=self.sd.choice(COLOR_CHOICES)) + + user.set_password('123123') + user.save() + + return user + + def create_votes(self, obj): + for i in range(self.sd.int(*NUM_VOTES)): + user=self.sd.db_object_from_queryset(User.objects.all()) + add_vote(obj, user) + + def create_likes(self, obj): + for i in range(self.sd.int(*NUM_LIKES)): + user=self.sd.db_object_from_queryset(User.objects.all()) + add_like(obj, user) + + def create_watchers(self, obj, notify_level=None): + for i in range(self.sd.int(*NUM_WATCHERS)): + user = self.sd.db_object_from_queryset(User.objects.all()) + if not notify_level: + obj.add_watcher(user) + else: + obj.add_watcher(user, notify_level) + + def generate_color(self, tag): + color = sha1(tag.encode("utf-8")).hexdigest()[0:6] + return "#{}".format(color) diff --git a/taiga/projects/management/commands/sample_data/sample_attachment_1.txt b/taiga/projects/management/commands/sample_data/sample_attachment_1.txt new file mode 100644 index 000000000..f374abfb1 --- /dev/null +++ b/taiga/projects/management/commands/sample_data/sample_attachment_1.txt @@ -0,0 +1,11 @@ +KALEIDOS MANIFESTO +================== + +* In the beginning, it's the people +* Code should be beautiful +* Customers are not a necessary evil +* Love your work and your work will be loved +* Shelfware is wrong +* Let us surprise you +* 1.618033988749894848204586834 +* In the end, it's the people diff --git a/taiga/projects/management/commands/sample_data/sample_attachment_2.txt b/taiga/projects/management/commands/sample_data/sample_attachment_2.txt new file mode 100644 index 000000000..83d5fee13 --- /dev/null +++ b/taiga/projects/management/commands/sample_data/sample_attachment_2.txt @@ -0,0 +1,15 @@ +WHAT IS THE PI WEEK? +==================== + +ΠWEEK /paɪ wiːk/ is an original idea by Kaleidos and it consists in allowing employees from participant technology companies to leave their ongoing work in standby and dedicate an entire week to personal projects. The plan is to enjoy a ΠWEEK every six months, particularly in December and July, and allow employees to play and innovate with only two rules in mind: + +* Only free & open source code can be used for development. +* By the end of the ΠWEEK, there must be a functional demo of some type. + +ΠWEEK stands for Personal Innovation Week and it is meant to foster innovation and creativity in a near limitless environment of several fellow companies. No commercial purpose is asked, no specific agenda imposed, just a week for everyone to do whatever they wish most. + +The mechanics is fairly simple. During the weeks prior to the ΠWEEK, some people will privately announce their projects so they can attract partners. This process naturally ends the Sunday before ΠWEEK starts so projects and teams are ready for action Monday morning. + +So far, there has been two ΠWEEKs, on December 2011, with just Kaleidos as a participant company and on July 2012, with Kaleidos, Secuoyas and Yaco. This edition, on December 2012, has Wadobo and Secuoyas as excited fellow companies. We will continue to invite more and more companies each edition until this becomes a nationwide event. + +The ultimate goal is to defy the current state of mind that relegates “innovation” to an empty word or, in the best case, to a top-down strategy. For the doubtful out there, try it before you despise it. The first to see the benefits were our clients. diff --git a/taiga/projects/management/commands/sample_data/sample_attachment_3.txt b/taiga/projects/management/commands/sample_data/sample_attachment_3.txt new file mode 100644 index 000000000..637b34018 --- /dev/null +++ b/taiga/projects/management/commands/sample_data/sample_attachment_3.txt @@ -0,0 +1,28 @@ +TAIGA TEAM +========== + +|-----------------------------------------------| +| NAME | TWITTER | JOB | +|-----------------------------------------------| +| Andrey Antukhi | @niwibe | Back Dev | +|-----------------------------------------------| +| Jesús Espino | @jespinog | Back Dev | +|-----------------------------------------------| +| David Barrgán | @bameda | Back Dev | +|-----------------------------------------------| +| Alejandro Alonso | @_superalex_ | Back Dev | +|-----------------------------------------------| +| Andrés Moya | @hirunatan | Back Dev | +|-----------------------------------------------| +| Alejandro Gómex | @dialelo | Back Dev | +|-----------------------------------------------| +| Alonso Torres | @Alotor | Back Dev | +|-----------------------------------------------| +| Xavier Julián | @Xaviju | Front Dev | +|-----------------------------------------------| +| Pilar Esteban | @devilme | UX/Designer | +|-----------------------------------------------| + +Kaleidos OpenSource SL - http://kaleidos.net +ΠWEEK (Personal Innovation Week) - http://piweek.es + diff --git a/taiga/projects/migrations/0001_initial.py b/taiga/projects/migrations/0001_initial.py new file mode 100644 index 000000000..7cec73ed5 --- /dev/null +++ b/taiga/projects/migrations/0001_initial.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import django.contrib.postgres.fields +import django.utils.timezone +import django.db.models.deletion +import taiga.projects.history.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0002_auto_20140903_0916'), + ] + + operations = [ + migrations.CreateModel( + name='Membership', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), + ('is_owner', models.BooleanField(default=False)), + ('email', models.EmailField(max_length=255, null=True, default=None, verbose_name='email', blank=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='creado el')), + ('token', models.CharField(max_length=60, null=True, default=None, verbose_name='token', blank=True)), + ('invited_by_id', models.IntegerField(null=True, blank=True)), + ], + options={ + 'ordering': ['project', 'user__full_name', 'user__username', 'user__email', 'email'], + 'verbose_name_plural': 'membershipss', + 'verbose_name': 'membership', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=list, null=True, size=None, verbose_name='tags')), + ('name', models.CharField(max_length=250, unique=True, verbose_name='name')), + ('slug', models.SlugField(max_length=250, unique=True, verbose_name='slug', blank=True)), + ('description', models.TextField(verbose_name='description')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('total_milestones', models.IntegerField(null=True, default=0, verbose_name='total of milestones', blank=True)), + ('total_story_points', models.FloatField(default=0, verbose_name='total story points')), + ('is_backlog_activated', models.BooleanField(default=True, verbose_name='active backlog panel')), + ('is_kanban_activated', models.BooleanField(default=False, verbose_name='active kanban panel')), + ('is_wiki_activated', models.BooleanField(default=True, verbose_name='active wiki panel')), + ('is_issues_activated', models.BooleanField(default=True, verbose_name='active issues panel')), + ('videoconferences', models.CharField(max_length=250, null=True, choices=[('appear-in', 'AppearIn'), ('talky', 'Talky')], verbose_name='videoconference system', blank=True)), + ('videoconferences_salt', models.CharField(max_length=250, null=True, verbose_name='videoconference room salt', blank=True)), + ('anon_permissions', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('view_us', 'View user stories'), ('view_tasks', 'View tasks'), ('view_issues', 'View issues'), ('view_wiki_pages', 'View wiki pages'), ('view_wiki_links', 'View wiki links')]), blank=True, default=list, null=True, size=None, verbose_name='anonymous permissions')), + ('public_permissions', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('view_us', 'View user stories'), ('view_issues', 'View issues'), ('vote_issues', 'Vote issues'), ('view_tasks', 'View tasks'), ('view_wiki_pages', 'View wiki pages'), ('view_wiki_links', 'View wiki links'), ('request_membership', 'Request membership'), ('add_us_to_project', 'Add user story to project'), ('add_comments_to_us', 'Add comments to user stories'), ('add_comments_to_task', 'Add comments to tasks'), ('add_issue', 'Add issues'), ('add_comments_issue', 'Add comments to issues'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='user permissions')), + ('is_private', models.BooleanField(default=False, verbose_name='is private')), + ('tags_colors', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), size=2), blank=True, default=list, null=True, size=None, verbose_name='tags colors')), + ], + options={ + 'ordering': ['name'], + 'verbose_name_plural': 'projects', + 'verbose_name': 'project', + }, + bases=(models.Model,), + ), + migrations.AddField( + model_name='project', + name='members', + field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, related_name='projects', verbose_name='members', through='projects.Membership'), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='owner', + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='owned_projects', verbose_name='owner', on_delete=models.SET_NULL), + preserve_default=True, + ), + + migrations.AddField( + model_name='membership', + name='user', + field=models.ForeignKey(blank=True, default=None, to=settings.AUTH_USER_MODEL, null=True, related_name='memberships', on_delete=models.CASCADE), + preserve_default=True, + ), + + migrations.AddField( + model_name='membership', + name='project', + field=models.ForeignKey(default=1, to='projects.Project', related_name='memberships', on_delete=models.CASCADE), + preserve_default=False, + ), + + migrations.AlterUniqueTogether( + name='membership', + unique_together=set([('user', 'project')]), + ), + + migrations.AddField( + model_name='membership', + name='role', + field=models.ForeignKey(related_name='memberships', to='users.Role', default=1, on_delete=models.CASCADE), + preserve_default=False, + ), + ] diff --git a/taiga/projects/migrations/0002_auto_20140903_0920.py b/taiga/projects/migrations/0002_auto_20140903_0920.py new file mode 100644 index 000000000..ad28147da --- /dev/null +++ b/taiga/projects/migrations/0002_auto_20140903_0920.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone +import taiga.base.db.models.fields +import django.db.models.deletion +import taiga.projects.history.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='IssueStatus', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(verbose_name='order', default=10)), + ('is_closed', models.BooleanField(verbose_name='is closed', default=False)), + ('color', models.CharField(max_length=20, verbose_name='color', default='#999999')), + ('project', models.ForeignKey(related_name='issue_statuses', to='projects.Project', verbose_name='project', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'issue statuses', + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'issue status', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='IssueType', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(verbose_name='order', default=10)), + ('color', models.CharField(max_length=20, verbose_name='color', default='#999999')), + ('project', models.ForeignKey(related_name='issue_types', to='projects.Project', verbose_name='project', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'issue types', + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'issue type', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Points', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(verbose_name='order', default=10)), + ('value', models.FloatField(blank=True, null=True, verbose_name='value', default=None)), + ('project', models.ForeignKey(related_name='points', to='projects.Project', verbose_name='project', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'points', + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'points', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Priority', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(verbose_name='order', default=10)), + ('color', models.CharField(max_length=20, verbose_name='color', default='#999999')), + ('project', models.ForeignKey(related_name='priorities', to='projects.Project', verbose_name='project', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'priorities', + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'priority', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='ProjectTemplate', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('name', models.CharField(max_length=250, verbose_name='name')), + ('slug', models.SlugField(max_length=250, blank=True, verbose_name='slug', unique=True)), + ('description', models.TextField(verbose_name='description')), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('default_owner_role', models.CharField(max_length=50, verbose_name="default owner's role")), + ('is_backlog_activated', models.BooleanField(verbose_name='active backlog panel', default=True)), + ('is_kanban_activated', models.BooleanField(verbose_name='active kanban panel', default=False)), + ('is_wiki_activated', models.BooleanField(verbose_name='active wiki panel', default=True)), + ('is_issues_activated', models.BooleanField(verbose_name='active issues panel', default=True)), + ('videoconferences', models.CharField(max_length=250, null=True, choices=[('appear-in', 'AppearIn'), ('talky', 'Talky')], verbose_name='videoconference system', blank=True)), + ('videoconferences_salt', models.CharField(max_length=250, null=True, verbose_name='videoconference room salt', blank=True)), + ('default_options', taiga.base.db.models.fields.JSONField(null=True, verbose_name='default options', blank=True)), + ('us_statuses', taiga.base.db.models.fields.JSONField(null=True, verbose_name='us statuses', blank=True)), + ('points', taiga.base.db.models.fields.JSONField(null=True, verbose_name='points', blank=True)), + ('task_statuses', taiga.base.db.models.fields.JSONField(null=True, verbose_name='task statuses', blank=True)), + ('issue_statuses', taiga.base.db.models.fields.JSONField(null=True, verbose_name='issue statuses', blank=True)), + ('issue_types', taiga.base.db.models.fields.JSONField(null=True, verbose_name='issue types', blank=True)), + ('priorities', taiga.base.db.models.fields.JSONField(null=True, verbose_name='priorities', blank=True)), + ('severities', taiga.base.db.models.fields.JSONField(null=True, verbose_name='severities', blank=True)), + ('roles', taiga.base.db.models.fields.JSONField(null=True, verbose_name='roles', blank=True)), + ], + options={ + 'verbose_name_plural': 'project templates', + 'verbose_name': 'project template', + 'ordering': ['name'], + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Severity', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(verbose_name='order', default=10)), + ('color', models.CharField(max_length=20, verbose_name='color', default='#999999')), + ('project', models.ForeignKey(related_name='severities', to='projects.Project', verbose_name='project', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'severities', + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'severity', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='TaskStatus', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(verbose_name='order', default=10)), + ('is_closed', models.BooleanField(verbose_name='is closed', default=False)), + ('color', models.CharField(max_length=20, verbose_name='color', default='#999999')), + ('project', models.ForeignKey(related_name='task_statuses', to='projects.Project', verbose_name='project', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'task statuses', + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'task status', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserStoryStatus', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(verbose_name='order', default=10)), + ('is_closed', models.BooleanField(verbose_name='is closed', default=False)), + ('color', models.CharField(max_length=20, verbose_name='color', default='#999999')), + ('wip_limit', models.IntegerField(blank=True, null=True, verbose_name='work in progress limit', default=None)), + ('project', models.ForeignKey(related_name='us_statuses', to='projects.Project', verbose_name='project', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'user story statuses', + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'user story status', + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='userstorystatus', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='taskstatus', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='severity', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='priority', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='points', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='issuetype', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='issuestatus', + unique_together=set([('project', 'name')]), + ), + migrations.AddField( + model_name='project', + name='creation_template', + field=models.ForeignKey(null=True, related_name='projects', default=None, blank=True, to='projects.ProjectTemplate', verbose_name='creation template', on_delete=models.SET_NULL), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='default_issue_status', + field=models.OneToOneField(to='projects.IssueStatus', null=True, related_name='+', blank=True, on_delete=django.db.models.deletion.SET_NULL, verbose_name='default issue status'), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='default_issue_type', + field=models.OneToOneField(to='projects.IssueType', null=True, related_name='+', blank=True, on_delete=django.db.models.deletion.SET_NULL, verbose_name='default issue type'), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='default_points', + field=models.OneToOneField(to='projects.Points', null=True, related_name='+', blank=True, on_delete=django.db.models.deletion.SET_NULL, verbose_name='default points'), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='default_priority', + field=models.OneToOneField(to='projects.Priority', null=True, related_name='+', blank=True, on_delete=django.db.models.deletion.SET_NULL, verbose_name='default priority'), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='default_severity', + field=models.OneToOneField(to='projects.Severity', null=True, related_name='+', blank=True, on_delete=django.db.models.deletion.SET_NULL, verbose_name='default severity'), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='default_task_status', + field=models.OneToOneField(to='projects.TaskStatus', null=True, related_name='+', blank=True, on_delete=django.db.models.deletion.SET_NULL, verbose_name='default task status'), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='default_us_status', + field=models.OneToOneField(to='projects.UserStoryStatus', null=True, related_name='+', blank=True, on_delete=django.db.models.deletion.SET_NULL, verbose_name='default US status'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0003_auto_20140913_1710.py b/taiga/projects/migrations/0003_auto_20140913_1710.py new file mode 100644 index 000000000..358debc90 --- /dev/null +++ b/taiga/projects/migrations/0003_auto_20140913_1710.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0002_auto_20140903_0920'), + ] + + operations = [ + migrations.RenameField( + model_name='membership', + old_name='invited_by_id', + new_name='invited_by_id_old', + ), + + migrations.AddField( + model_name='membership', + name='invited_by', + field=models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, blank=True, related_name='ihaveinvited+', on_delete=models.SET_NULL), + preserve_default=True, + ), + + migrations.RunSQL("UPDATE projects_membership SET invited_by_id = invited_by_id_old"), + + migrations.RemoveField( + model_name='membership', + name='invited_by_id_old', + ), + + ] diff --git a/taiga/projects/migrations/0004_auto_20141002_2337.py b/taiga/projects/migrations/0004_auto_20141002_2337.py new file mode 100644 index 000000000..84e640749 --- /dev/null +++ b/taiga/projects/migrations/0004_auto_20141002_2337.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0003_auto_20140913_1710'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='name', + field=models.CharField(max_length=250, verbose_name='name'), + ), + ] diff --git a/taiga/projects/migrations/0005_membership_invitation_extra_text.py b/taiga/projects/migrations/0005_membership_invitation_extra_text.py new file mode 100644 index 000000000..af10a0575 --- /dev/null +++ b/taiga/projects/migrations/0005_membership_invitation_extra_text.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0004_auto_20141002_2337'), + ] + + operations = [ + migrations.AddField( + model_name='membership', + name='invitation_extra_text', + field=models.TextField(null=True, verbose_name='invitation extra text', blank=True), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0006_auto_20141029_1040.py b/taiga/projects/migrations/0006_auto_20141029_1040.py new file mode 100644 index 000000000..06af53008 --- /dev/null +++ b/taiga/projects/migrations/0006_auto_20141029_1040.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + +def update_total_milestones(apps, schema_editor): + Project = apps.get_model("projects", "Project") + qs = Project.objects.filter(total_milestones__isnull=True) + qs.update(total_milestones=0) + + +class Migration(migrations.Migration): + dependencies = [ + ('projects', '0005_membership_invitation_extra_text'), + ] + + operations = [ + migrations.RunPython(update_total_milestones), + migrations.AlterField( + model_name='project', + name='total_milestones', + field=models.IntegerField(null=False, blank=False, default=0, verbose_name='total of milestones'), + ), + ] diff --git a/taiga/projects/migrations/0007_auto_20141024_1011.py b/taiga/projects/migrations/0007_auto_20141024_1011.py new file mode 100644 index 000000000..27a4e7ced --- /dev/null +++ b/taiga/projects/migrations/0007_auto_20141024_1011.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0006_auto_20141029_1040'), + ] + + operations = [ + migrations.AddField( + model_name='issuestatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255, null=True), + preserve_default=True, + ), + migrations.AddField( + model_name='taskstatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255, null=True), + preserve_default=True, + ), + migrations.AddField( + model_name='userstorystatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255, null=True), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0008_auto_20141024_1012.py b/taiga/projects/migrations/0008_auto_20141024_1012.py new file mode 100644 index 000000000..102620f23 --- /dev/null +++ b/taiga/projects/migrations/0008_auto_20141024_1012.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from unidecode import unidecode + +from django.db import models, migrations +from django.template.defaultfilters import slugify + +from taiga.projects.models import UserStoryStatus, TaskStatus, IssueStatus + +def update_many(objects, fields=[], using="default"): + """Update list of Django objects in one SQL query, optionally only + overwrite the given fields (as names, e.g. fields=["foo"]). + Objects must be of the same Django model. Note that save is not + called and signals on the model are not raised.""" + if not objects: + return + + import django.db.models + from django.db import connections + con = connections[using] + + names = fields + meta = objects[0]._meta + fields = [f for f in meta.fields if not isinstance(f, django.db.models.AutoField) and (not names or f.name in names)] + + if not fields: + raise ValueError("No fields to update, field names are %s." % names) + + fields_with_pk = fields + [meta.pk] + parameters = [] + for o in objects: + parameters.append(tuple(f.get_db_prep_save(f.pre_save(o, True), connection=con) for f in fields_with_pk)) + + table = meta.db_table + assignments = ",".join(("%s=%%s"% con.ops.quote_name(f.column)) for f in fields) + con.cursor().executemany( + "update %s set %s where %s=%%s" % (table, assignments, con.ops.quote_name(meta.pk.column)), + parameters) + + +def update_slug(apps, schema_editor): + update_qs = UserStoryStatus.objects.all().only("name") + for us_status in update_qs: + us_status.slug = slugify(unidecode(us_status.name)) + + update_many(update_qs, fields=["slug"]) + + update_qs = TaskStatus.objects.all().only("name") + for task_status in update_qs: + task_status.slug = slugify(unidecode(task_status.name)) + + update_many(update_qs, fields=["slug"]) + + update_qs = IssueStatus.objects.all().only("name") + for issue_status in update_qs: + issue_status.slug = slugify(unidecode(issue_status.name)) + + update_many(update_qs, fields=["slug"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0007_auto_20141024_1011'), + ] + + operations = [ + migrations.RunPython(update_slug) + ] diff --git a/taiga/projects/migrations/0009_auto_20141024_1037.py b/taiga/projects/migrations/0009_auto_20141024_1037.py new file mode 100644 index 000000000..c9019a29a --- /dev/null +++ b/taiga/projects/migrations/0009_auto_20141024_1037.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0008_auto_20141024_1012'), + ] + + operations = [ + migrations.AlterField( + model_name='issuestatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255), + ), + migrations.AlterField( + model_name='taskstatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255), + ), + migrations.AlterField( + model_name='userstorystatus', + name='slug', + field=models.SlugField(verbose_name='slug', blank=True, max_length=255), + ), + migrations.AlterUniqueTogether( + name='issuestatus', + unique_together=set([('project', 'name'), ('project', 'slug')]), + ), + migrations.AlterUniqueTogether( + name='taskstatus', + unique_together=set([('project', 'name'), ('project', 'slug')]), + ), + migrations.AlterUniqueTogether( + name='userstorystatus', + unique_together=set([('project', 'name'), ('project', 'slug')]), + ), + ] diff --git a/taiga/projects/migrations/0010_project_modules_config.py b/taiga/projects/migrations/0010_project_modules_config.py new file mode 100644 index 000000000..04de77e8a --- /dev/null +++ b/taiga/projects/migrations/0010_project_modules_config.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0009_auto_20141024_1037'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='modules_config', + field=taiga.base.db.models.fields.JSONField(blank=True, null=True, verbose_name='modules config'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0011_auto_20141028_2057.py b/taiga/projects/migrations/0011_auto_20141028_2057.py new file mode 100644 index 000000000..43724b2d0 --- /dev/null +++ b/taiga/projects/migrations/0011_auto_20141028_2057.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0010_project_modules_config'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectModulesConfig', + fields=[ + ('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), + ('config', taiga.base.db.models.fields.JSONField(null=True, verbose_name='modules config', blank=True)), + ('project', models.OneToOneField(to='projects.Project', verbose_name='project', related_name='modules_config', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'project modules configs', + 'verbose_name': 'project modules config', + 'ordering': ['project'], + }, + bases=(models.Model,), + ), + migrations.RemoveField( + model_name='project', + name='modules_config', + ), + ] diff --git a/taiga/projects/migrations/0012_auto_20141210_1009.py b/taiga/projects/migrations/0012_auto_20141210_1009.py new file mode 100644 index 000000000..32ef33b7c --- /dev/null +++ b/taiga/projects/migrations/0012_auto_20141210_1009.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.template.defaultfilters import slugify + + +def fix_project_template_slugs(apps, schema_editor): + ProjectTemplate = apps.get_model("projects", "ProjectTemplate") + for pt in ProjectTemplate.objects.all(): + for us_status in pt.us_statuses: + us_status["slug"] = slugify(us_status["name"]) + for task_status in pt.task_statuses: + task_status["slug"] = slugify(task_status["name"]) + for issue_status in pt.issue_statuses: + issue_status["slug"] = slugify(issue_status["name"]) + pt.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0011_auto_20141028_2057'), + ] + + operations = [ + migrations.RunPython(fix_project_template_slugs), + ] diff --git a/taiga/projects/migrations/0013_auto_20141210_1040.py b/taiga/projects/migrations/0013_auto_20141210_1040.py new file mode 100644 index 000000000..08f988044 --- /dev/null +++ b/taiga/projects/migrations/0013_auto_20141210_1040.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db import connection +from taiga.projects.userstories.models import * +from taiga.projects.tasks.models import * +from taiga.projects.issues.models import * +from taiga.projects.models import * + +def _fix_tags_model(tags_model): + table_name = tags_model._meta.db_table + query = "select id from (select id, unnest(tags) tag from %s) x where tag LIKE '%%,%%'"%(table_name) + cursor = connection.cursor() + cursor.execute(query) + for row in cursor.fetchall(): + id = row[0] + instance = tags_model.objects.get(id=id) + instance.tags = [tag.replace(",", "") for tag in instance.tags] + instance.save() + + +def fix_tags(apps, schema_editor): + _fix_tags_model(Project) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0012_auto_20141210_1009'), + ] + + operations = [ + migrations.RunPython(fix_tags), + ] diff --git a/taiga/projects/migrations/0014_userstorystatus_is_archived.py b/taiga/projects/migrations/0014_userstorystatus_is_archived.py new file mode 100644 index 000000000..7cd190eef --- /dev/null +++ b/taiga/projects/migrations/0014_userstorystatus_is_archived.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0013_auto_20141210_1040'), + ] + + operations = [ + migrations.AddField( + model_name='userstorystatus', + name='is_archived', + field=models.BooleanField(default=False, verbose_name='is archived'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0015_auto_20141230_1212.py b/taiga/projects/migrations/0015_auto_20141230_1212.py new file mode 100644 index 000000000..2821bde4d --- /dev/null +++ b/taiga/projects/migrations/0015_auto_20141230_1212.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals +from django.db import models, migrations + + +def fix_project_template_us_status_archived(apps, schema_editor): + ProjectTemplate = apps.get_model("projects", "ProjectTemplate") + for pt in ProjectTemplate.objects.all(): + for us_status in pt.us_statuses: + us_status["is_archived"] = False + + pt.us_statuses.append({ + "color": "#5c3566", + "order": 6, + "is_closed": True, + "is_archived": True, + "wip_limit": None, + "name": "Archived", + "slug": "archived"}) + + pt.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0014_userstorystatus_is_archived'), + ] + + operations = [ + migrations.RunPython(fix_project_template_us_status_archived), + ] diff --git a/taiga/projects/migrations/0016_fix_json_field_not_null.py b/taiga/projects/migrations/0016_fix_json_field_not_null.py new file mode 100644 index 000000000..a2399f9ab --- /dev/null +++ b/taiga/projects/migrations/0016_fix_json_field_not_null.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from taiga.base.db.models.fields import JSONField + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0015_auto_20141230_1212'), + ] + + operations = [ + migrations.RunSQL( + sql='ALTER TABLE projects_projectmodulesconfig ALTER COLUMN config DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN default_options DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN us_statuses DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN points DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN task_statuses DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN issue_statuses DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN issue_types DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN priorities DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN severities DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN roles DROP NOT NULL;', + ), + ] diff --git a/taiga/projects/migrations/0017_fix_is_private_for_projects.py b/taiga/projects/migrations/0017_fix_is_private_for_projects.py new file mode 100644 index 000000000..02d7e76eb --- /dev/null +++ b/taiga/projects/migrations/0017_fix_is_private_for_projects.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + +def update_existing_projects(apps, schema_editor): + Project = apps.get_model("projects", "Project") + Project.objects.filter(is_private=False).update(is_private=True) + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0016_fix_json_field_not_null'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='is_private', + field=models.BooleanField(verbose_name='is private', default=True), + preserve_default=True, + ), + migrations.RunPython(update_existing_projects), + ] diff --git a/taiga/projects/migrations/0018_auto_20150219_1606.py b/taiga/projects/migrations/0018_auto_20150219_1606.py new file mode 100644 index 000000000..b0c30d7be --- /dev/null +++ b/taiga/projects/migrations/0018_auto_20150219_1606.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0017_fix_is_private_for_projects'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='issues_csv_uuid', + field=models.CharField(editable=False, max_length=32, default=None, null=True, db_index=True, blank=True), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='tasks_csv_uuid', + field=models.CharField(editable=False, max_length=32, default=None, null=True, db_index=True, blank=True), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='userstories_csv_uuid', + field=models.CharField(editable=False, max_length=32, default=None, null=True, db_index=True, blank=True), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0019_auto_20150311_0821.py b/taiga/projects/migrations/0019_auto_20150311_0821.py new file mode 100644 index 000000000..47eadfa3a --- /dev/null +++ b/taiga/projects/migrations/0019_auto_20150311_0821.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0018_auto_20150219_1606'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='public_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('vote_issues', 'Vote issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='user permissions'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0020_membership_user_order.py b/taiga/projects/migrations/0020_membership_user_order.py new file mode 100644 index 000000000..79ec093aa --- /dev/null +++ b/taiga/projects/migrations/0020_membership_user_order.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0019_auto_20150311_0821'), + ] + + operations = [ + migrations.AddField( + model_name='membership', + name='user_order', + field=models.IntegerField(default=10000, verbose_name='user order'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0021_auto_20150504_1524.py b/taiga/projects/migrations/0021_auto_20150504_1524.py new file mode 100644 index 000000000..78eb47e38 --- /dev/null +++ b/taiga/projects/migrations/0021_auto_20150504_1524.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0020_membership_user_order'), + ] + + operations = [ + migrations.AlterField( + model_name='membership', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='create at'), + preserve_default=True, + ), + migrations.AlterField( + model_name='project', + name='videoconferences', + field=models.CharField(max_length=250, blank=True, choices=[('appear-in', 'AppearIn'), ('jitsi', 'Jitsi'), ('talky', 'Talky')], null=True, verbose_name='videoconference system'), + preserve_default=True, + ), + migrations.AlterField( + model_name='projecttemplate', + name='videoconferences', + field=models.CharField(max_length=250, blank=True, choices=[('appear-in', 'AppearIn'), ('jitsi', 'Jitsi'), ('talky', 'Talky')], null=True, verbose_name='videoconference system'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0022_auto_20150701_0924.py b/taiga/projects/migrations/0022_auto_20150701_0924.py new file mode 100644 index 000000000..9ad5871af --- /dev/null +++ b/taiga/projects/migrations/0022_auto_20150701_0924.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0021_auto_20150504_1524'), + ] + + operations = [ + migrations.RenameField( + model_name='projecttemplate', + old_name='videoconferences_salt', + new_name='videoconferences_extra_data', + ), + migrations.RenameField( + model_name='project', + old_name='videoconferences_salt', + new_name='videoconferences_extra_data', + ), + migrations.AlterField( + model_name='project', + name='videoconferences', + field=models.CharField(blank=True, verbose_name='videoconference system', choices=[('appear-in', 'AppearIn'), ('jitsi', 'Jitsi'), ('custom', 'Custom'), ('talky', 'Talky')], null=True, max_length=250), + preserve_default=True, + ), + migrations.AlterField( + model_name='projecttemplate', + name='videoconferences', + field=models.CharField(blank=True, verbose_name='videoconference system', choices=[('appear-in', 'AppearIn'), ('jitsi', 'Jitsi'), ('custom', 'Custom'), ('talky', 'Talky')], null=True, max_length=250), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0023_auto_20150721_1511.py b/taiga/projects/migrations/0023_auto_20150721_1511.py new file mode 100644 index 000000000..9cbb586c7 --- /dev/null +++ b/taiga/projects/migrations/0023_auto_20150721_1511.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0022_auto_20150701_0924'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='videoconferences_extra_data', + field=models.CharField(max_length=250, blank=True, null=True, verbose_name='videoconference extra data'), + preserve_default=True, + ), + migrations.AlterField( + model_name='projecttemplate', + name='videoconferences_extra_data', + field=models.CharField(max_length=250, blank=True, null=True, verbose_name='videoconference extra data'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0024_auto_20150810_1247.py b/taiga/projects/migrations/0024_auto_20150810_1247.py new file mode 100644 index 000000000..cd856d421 --- /dev/null +++ b/taiga/projects/migrations/0024_auto_20150810_1247.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0023_auto_20150721_1511'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='public_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('star_project', 'Star project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('vote_us', 'Vote user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('vote_task', 'Vote task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('vote_issue', 'Vote issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='user permissions'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0025_auto_20150901_1600.py b/taiga/projects/migrations/0025_auto_20150901_1600.py new file mode 100644 index 000000000..5c71aaec1 --- /dev/null +++ b/taiga/projects/migrations/0025_auto_20150901_1600.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0024_auto_20150810_1247'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='public_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='user permissions'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0026_auto_20150911_1237.py b/taiga/projects/migrations/0026_auto_20150911_1237.py new file mode 100644 index 000000000..efcde8463 --- /dev/null +++ b/taiga/projects/migrations/0026_auto_20150911_1237.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import connection +from django.db import migrations + + +def create_postgres_search_dictionary(apps, schema_editor): + sql=""" +CREATE TEXT SEARCH DICTIONARY english_stem_nostop ( + Template = snowball, + Language = english +); +CREATE TEXT SEARCH CONFIGURATION public.english_nostop ( COPY = pg_catalog.english ); +ALTER TEXT SEARCH CONFIGURATION public.english_nostop +ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, hword, hword_part, word WITH english_stem_nostop; +""" + cursor = connection.cursor() + cursor.execute(sql) + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0025_auto_20150901_1600'), + ] + + operations = [ + migrations.RunPython(create_postgres_search_dictionary), + ] diff --git a/taiga/projects/migrations/0027_auto_20150916_1302.py b/taiga/projects/migrations/0027_auto_20150916_1302.py new file mode 100644 index 000000000..ef4a1286c --- /dev/null +++ b/taiga/projects/migrations/0027_auto_20150916_1302.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0026_auto_20150911_1237'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='total_milestones', + field=models.IntegerField(verbose_name='total of milestones', null=True, blank=True), + preserve_default=True, + ), + migrations.AlterField( + model_name='project', + name='total_story_points', + field=models.FloatField(verbose_name='total story points', null=True, blank=True), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0028_project_is_featured.py b/taiga/projects/migrations/0028_project_is_featured.py new file mode 100644 index 000000000..f2e52fd9f --- /dev/null +++ b/taiga/projects/migrations/0028_project_is_featured.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0027_auto_20150916_1302'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='is_featured', + field=models.BooleanField(verbose_name='is featured', default=False), + ), + ] diff --git a/taiga/projects/migrations/0029_project_is_looking_for_people.py b/taiga/projects/migrations/0029_project_is_looking_for_people.py new file mode 100644 index 000000000..22be78bfb --- /dev/null +++ b/taiga/projects/migrations/0029_project_is_looking_for_people.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0028_project_is_featured'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='is_looking_for_people', + field=models.BooleanField(verbose_name='is looking for people', default=False), + ), + ] diff --git a/taiga/projects/migrations/0030_auto_20151128_0757.py b/taiga/projects/migrations/0030_auto_20151128_0757.py new file mode 100644 index 000000000..4a4b3583d --- /dev/null +++ b/taiga/projects/migrations/0030_auto_20151128_0757.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import connection, migrations, models +from django.utils.timezone import utc +import datetime + + +def update_totals(apps, schema_editor): + model = apps.get_model("projects", "Project") + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + sql=""" + UPDATE projects_project + SET + totals_updated_datetime = totals.totals_updated_datetime, + total_fans = totals.total_fans, + total_fans_last_week = totals.total_fans_last_week, + total_fans_last_month = totals.total_fans_last_month, + total_fans_last_year = totals.total_fans_last_year, + total_activity = totals.total_activity, + total_activity_last_week = totals.total_activity_last_week, + total_activity_last_month = totals.total_activity_last_month, + total_activity_last_year = totals.total_activity_last_year + FROM ( + WITH + totals_activity AS (SELECT + split_part(timeline_timeline.namespace, ':', 2)::integer as project_id, + count(timeline_timeline.namespace) total_activity, + MAX (created) updated_datetime + FROM timeline_timeline + WHERE namespace LIKE 'project:%' + GROUP BY namespace), + totals_activity_week AS (SELECT + split_part(timeline_timeline.namespace, ':', 2)::integer as project_id, + count(timeline_timeline.namespace) total_activity_last_week + FROM timeline_timeline + WHERE namespace LIKE 'project:%' + AND timeline_timeline.created > current_date - interval '7' day + GROUP BY namespace), + totals_activity_month AS (SELECT + split_part(timeline_timeline.namespace, ':', 2)::integer as project_id, + count(timeline_timeline.namespace) total_activity_last_month + FROM timeline_timeline + WHERE namespace LIKE 'project:%' + AND timeline_timeline.created > current_date - interval '30' day + GROUP BY namespace), + totals_activity_year AS (SELECT + split_part(timeline_timeline.namespace, ':', 2)::integer as project_id, + count(timeline_timeline.namespace) total_activity_last_year + FROM timeline_timeline + WHERE namespace LIKE 'project:%' + AND timeline_timeline.created > current_date - interval '365' day + GROUP BY namespace), + totals_fans AS (SELECT + object_id as project_id, + COUNT(likes_like.object_id) total_fans, + MAX (created_date) updated_datetime + FROM likes_like + WHERE content_type_id = {type_id} + GROUP BY object_id), + totals_fans_week AS (SELECT + object_id as project_id, + COUNT(likes_like.object_id) total_fans_last_week + FROM likes_like + WHERE content_type_id = {type_id} + AND likes_like.created_date > current_date - interval '7' day + GROUP BY object_id), + totals_fans_month AS (SELECT + object_id as project_id, + COUNT(likes_like.object_id) total_fans_last_month + FROM likes_like + WHERE content_type_id = {type_id} + AND likes_like.created_date > current_date - interval '30' day + GROUP BY object_id), + totals_fans_year AS (SELECT + object_id as project_id, + COUNT(likes_like.object_id) total_fans_last_year + FROM likes_like + WHERE content_type_id = {type_id} + AND likes_like.created_date > current_date - interval '365' day + GROUP BY object_id) + SELECT + totals_activity.project_id, + COALESCE(total_activity, 0) total_activity, + COALESCE(total_activity_last_week, 0) total_activity_last_week, + COALESCE(total_activity_last_month, 0) total_activity_last_month, + COALESCE(total_activity_last_year, 0) total_activity_last_year, + COALESCE(total_fans, 0) total_fans, + COALESCE(total_fans_last_week, 0) total_fans_last_week, + COALESCE(total_fans_last_month, 0) total_fans_last_month, + COALESCE(total_fans_last_year, 0) total_fans_last_year, + totals_activity.updated_datetime totals_updated_datetime + FROM totals_activity + LEFT JOIN totals_fans ON totals_activity.project_id = totals_fans.project_id + LEFT JOIN totals_fans_week ON totals_activity.project_id = totals_fans_week.project_id + LEFT JOIN totals_fans_month ON totals_activity.project_id = totals_fans_month.project_id + LEFT JOIN totals_fans_year ON totals_activity.project_id = totals_fans_year.project_id + LEFT JOIN totals_activity_week ON totals_activity.project_id = totals_activity_week.project_id + LEFT JOIN totals_activity_month ON totals_activity.project_id = totals_activity_month.project_id + LEFT JOIN totals_activity_year ON totals_activity.project_id = totals_activity_year.project_id + ) totals + WHERE projects_project.id = totals.project_id + """.format(type_id=type.id) + + cursor = connection.cursor() + cursor.execute(sql) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0029_project_is_looking_for_people'), + ('likes', '0001_initial'), + ('timeline', '0004_auto_20150603_1312'), + ('likes', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='total_activity', + field=models.PositiveIntegerField(default=0, verbose_name='count', db_index=True), + ), + migrations.AddField( + model_name='project', + name='total_activity_last_month', + field=models.PositiveIntegerField(default=0, verbose_name='activity last month', db_index=True), + ), + migrations.AddField( + model_name='project', + name='total_activity_last_week', + field=models.PositiveIntegerField(default=0, verbose_name='activity last week', db_index=True), + ), + migrations.AddField( + model_name='project', + name='total_activity_last_year', + field=models.PositiveIntegerField(default=0, verbose_name='activity last year', db_index=True), + ), + migrations.AddField( + model_name='project', + name='total_fans', + field=models.PositiveIntegerField(default=0, verbose_name='count', db_index=True), + ), + migrations.AddField( + model_name='project', + name='total_fans_last_month', + field=models.PositiveIntegerField(default=0, verbose_name='fans last month', db_index=True), + ), + migrations.AddField( + model_name='project', + name='total_fans_last_week', + field=models.PositiveIntegerField(default=0, verbose_name='fans last week', db_index=True), + ), + migrations.AddField( + model_name='project', + name='total_fans_last_year', + field=models.PositiveIntegerField(default=0, verbose_name='fans last year', db_index=True), + ), + migrations.AddField( + model_name='project', + name='totals_updated_datetime', + field=models.DateTimeField(default=datetime.datetime(2015, 11, 28, 7, 57, 11, 743976, tzinfo=utc), auto_now_add=True, verbose_name='updated date time', db_index=True), + preserve_default=False, + ), + migrations.RunPython(update_totals), + ] diff --git a/taiga/projects/migrations/0031_project_logo.py b/taiga/projects/migrations/0031_project_logo.py new file mode 100644 index 000000000..e12955ca4 --- /dev/null +++ b/taiga/projects/migrations/0031_project_logo.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0030_auto_20151128_0757'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='logo', + field=models.FileField(null=True, blank=True, upload_to=taiga.projects.models.get_project_logo_file_path, verbose_name='logo', max_length=500), + ), + ] diff --git a/taiga/projects/migrations/0032_auto_20151202_1151.py b/taiga/projects/migrations/0032_auto_20151202_1151.py new file mode 100644 index 000000000..ca88d71af --- /dev/null +++ b/taiga/projects/migrations/0032_auto_20151202_1151.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0031_project_logo'), + ] + + operations = [ + migrations.AlterModelOptions( + name='project', + options={'ordering': ['name', 'id'], 'verbose_name': 'project', 'verbose_name_plural': 'projects'}, + ), + migrations.AlterIndexTogether( + name='project', + index_together=set([('name', 'id')]), + ), + ] diff --git a/taiga/projects/migrations/0033_text_search_indexes.py b/taiga/projects/migrations/0033_text_search_indexes.py new file mode 100644 index 000000000..42fe8a0f2 --- /dev/null +++ b/taiga/projects/migrations/0033_text_search_indexes.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations + + +DROP_INMUTABLE_ARRAY_TO_STRING_FUNCTION = """ + DROP FUNCTION IF EXISTS inmutable_array_to_string(text[]) CASCADE +""" + + +# NOTE: This function is needed by taiga.projects.filters.QFilter +CREATE_INMUTABLE_ARRAY_TO_STRING_FUNCTION = """ + CREATE OR REPLACE FUNCTION inmutable_array_to_string(text[]) + RETURNS text + LANGUAGE sql + IMMUTABLE AS $$SELECT array_to_string($1, ' ', '')$$ +""" + + +DROP_INDEX = """ + DROP INDEX IF EXISTS projects_project_textquery_idx; +""" + + +# NOTE: This index is needed by taiga.projects.filters.QFilter +CREATE_INDEX = """ + CREATE INDEX projects_project_textquery_idx + ON projects_project + USING gin((setweight(to_tsvector('english_nostop', + coalesce(projects_project.name, '')), 'A') || + setweight(to_tsvector('english_nostop', + coalesce(inmutable_array_to_string(projects_project.tags), '')), 'B') || + setweight(to_tsvector('english_nostop', + coalesce(projects_project.description, '')), 'C'))); +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0032_auto_20151202_1151'), + ] + + operations = [ + migrations.RunSQL([DROP_INMUTABLE_ARRAY_TO_STRING_FUNCTION, CREATE_INMUTABLE_ARRAY_TO_STRING_FUNCTION], + [DROP_INMUTABLE_ARRAY_TO_STRING_FUNCTION]), + migrations.RunSQL([DROP_INDEX, CREATE_INDEX], + [DROP_INDEX]), + ] diff --git a/taiga/projects/migrations/0034_project_looking_for_people_note.py b/taiga/projects/migrations/0034_project_looking_for_people_note.py new file mode 100644 index 000000000..876176c4c --- /dev/null +++ b/taiga/projects/migrations/0034_project_looking_for_people_note.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0033_text_search_indexes'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='looking_for_people_note', + field=models.TextField(blank=True, verbose_name='loking for people note', default=''), + ), + ] diff --git a/taiga/projects/migrations/0035_project_blocked_code.py b/taiga/projects/migrations/0035_project_blocked_code.py new file mode 100644 index 000000000..2fcbd862d --- /dev/null +++ b/taiga/projects/migrations/0035_project_blocked_code.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0034_project_looking_for_people_note'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='blocked_code', + field=models.CharField(choices=[('blocked-by-staff', 'This project was blocked by staff'), ('blocked-by-owner-leaving', 'This project was because the owner left')], null=True, default=None, max_length=255, blank=True, verbose_name='blocked code'), + ), + ] diff --git a/taiga/projects/migrations/0036_project_transfer_token.py b/taiga/projects/migrations/0036_project_transfer_token.py new file mode 100644 index 000000000..9505f73b5 --- /dev/null +++ b/taiga/projects/migrations/0036_project_transfer_token.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0035_project_blocked_code'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='transfer_token', + field=models.CharField(max_length=255, default=None, blank=True, null=True, verbose_name='project transfer token'), + ), + ] diff --git a/taiga/projects/migrations/0037_auto_20160208_1751.py b/taiga/projects/migrations/0037_auto_20160208_1751.py new file mode 100644 index 000000000..b936b60c7 --- /dev/null +++ b/taiga/projects/migrations/0037_auto_20160208_1751.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0036_project_transfer_token'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='blocked_code', + field=models.CharField(max_length=255, blank=True, verbose_name='blocked code', choices=[('blocked-by-staff', 'This project was blocked by staff'), ('blocked-by-owner-leaving', 'This project was blocked because the owner left')], null=True, default=None), + ), + ] diff --git a/taiga/projects/migrations/0038_auto_20160215_1133.py b/taiga/projects/migrations/0038_auto_20160215_1133.py new file mode 100644 index 000000000..18c7f2543 --- /dev/null +++ b/taiga/projects/migrations/0038_auto_20160215_1133.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-02-15 11:33 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0037_auto_20160208_1751'), + ] + + operations = [ + migrations.RenameField( + model_name='membership', + old_name='is_owner', + new_name='is_admin', + ), + ] diff --git a/taiga/projects/migrations/0039_auto_20160322_1157.py b/taiga/projects/migrations/0039_auto_20160322_1157.py new file mode 100644 index 000000000..aee9f9da8 --- /dev/null +++ b/taiga/projects/migrations/0039_auto_20160322_1157.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-03-22 11:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0038_auto_20160215_1133'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='blocked_code', + field=models.CharField(blank=True, choices=[('blocked-by-nonpayment', 'This project is blocked due to payment failure'), ('blocked-by-staff', 'This project is blocked by admin staff'), ('blocked-by-owner-leaving', 'This project is blocked because the owner left')], default=None, max_length=255, null=True, verbose_name='blocked code'), + ), + ] diff --git a/taiga/projects/migrations/0040_remove_memberships_of_cancelled_users_acounts.py b/taiga/projects/migrations/0040_remove_memberships_of_cancelled_users_acounts.py new file mode 100644 index 000000000..1cbeb6cd1 --- /dev/null +++ b/taiga/projects/migrations/0040_remove_memberships_of_cancelled_users_acounts.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-04-06 15:46 +from __future__ import unicode_literals + +from django.db import migrations + + +def remove_memberships_of_cancelled_users_acounts(apps, schema_editor): + Membership = apps.get_model("projects", "Membership") + Membership.objects.filter(user__is_active=False).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0039_auto_20160322_1157'), + ] + + operations = [ + migrations.RunPython(remove_memberships_of_cancelled_users_acounts), + ] diff --git a/taiga/projects/migrations/0041_auto_20160519_1058.py b/taiga/projects/migrations/0041_auto_20160519_1058.py new file mode 100644 index 000000000..9c2b52e77 --- /dev/null +++ b/taiga/projects/migrations/0041_auto_20160519_1058.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-05-19 10:58 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0040_remove_memberships_of_cancelled_users_acounts'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='public_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='user permissions'), + + ), + ] diff --git a/taiga/projects/migrations/0042_auto_20160525_0911.py b/taiga/projects/migrations/0042_auto_20160525_0911.py new file mode 100644 index 000000000..4ff0e9a54 --- /dev/null +++ b/taiga/projects/migrations/0042_auto_20160525_0911.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-05-25 09:11 +from __future__ import unicode_literals + +from django.db import migrations + + +UPDATE_PROJECTS_ANON_PERMISSIONS_SQL = """ + UPDATE projects_project + SET + ANON_PERMISSIONS = array_append(ANON_PERMISSIONS, '{comment_permission}') + WHERE + '{base_permission}' = ANY(ANON_PERMISSIONS) + AND + NOT '{comment_permission}' = ANY(ANON_PERMISSIONS) +""" + +UPDATE_PROJECTS_PUBLIC_PERMISSIONS_SQL = """ + UPDATE projects_project + SET + PUBLIC_PERMISSIONS = array_append(PUBLIC_PERMISSIONS, '{comment_permission}') + WHERE + '{base_permission}' = ANY(PUBLIC_PERMISSIONS) + AND + NOT '{comment_permission}' = ANY(PUBLIC_PERMISSIONS) +""" + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0041_auto_20160519_1058'), + ] + + operations = [ + # user stories + migrations.RunSQL(UPDATE_PROJECTS_ANON_PERMISSIONS_SQL.format( + base_permission="modify_us", + comment_permission="comment_us") + ), + + migrations.RunSQL(UPDATE_PROJECTS_PUBLIC_PERMISSIONS_SQL.format( + base_permission="modify_us", + comment_permission="comment_us") + ), + + # tasks + migrations.RunSQL(UPDATE_PROJECTS_ANON_PERMISSIONS_SQL.format( + base_permission="modify_task", + comment_permission="comment_task") + ), + + migrations.RunSQL(UPDATE_PROJECTS_PUBLIC_PERMISSIONS_SQL.format( + base_permission="modify_task", + comment_permission="comment_task") + ), + + # issues + migrations.RunSQL(UPDATE_PROJECTS_ANON_PERMISSIONS_SQL.format( + base_permission="modify_issue", + comment_permission="comment_issue") + ), + + migrations.RunSQL(UPDATE_PROJECTS_PUBLIC_PERMISSIONS_SQL.format( + base_permission="modify_issue", + comment_permission="comment_issue") + ) + ] diff --git a/taiga/projects/migrations/0043_auto_20160530_1004.py b/taiga/projects/migrations/0043_auto_20160530_1004.py new file mode 100644 index 000000000..0efbf943f --- /dev/null +++ b/taiga/projects/migrations/0043_auto_20160530_1004.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-05-30 10:04 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0040_remove_memberships_of_cancelled_users_acounts'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_projects', to=settings.AUTH_USER_MODEL, verbose_name='owner'), + ), + ] diff --git a/taiga/projects/migrations/0044_auto_20160531_1150.py b/taiga/projects/migrations/0044_auto_20160531_1150.py new file mode 100644 index 000000000..75e1b438a --- /dev/null +++ b/taiga/projects/migrations/0044_auto_20160531_1150.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-05-31 11:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0043_auto_20160530_1004'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='blocked_code', + field=models.CharField(blank=True, choices=[('blocked-by-nonpayment', 'This project is blocked due to payment failure'), ('blocked-by-staff', 'This project is blocked by admin staff'), ('blocked-by-owner-leaving', 'This project is blocked because the owner left'), ('blocked-by-deleting', "This project is blocked while it's deleted")], default=None, max_length=255, null=True, verbose_name='blocked code'), + ), + ] diff --git a/taiga/projects/migrations/0045_merge.py b/taiga/projects/migrations/0045_merge.py new file mode 100644 index 000000000..637a549d7 --- /dev/null +++ b/taiga/projects/migrations/0045_merge.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-05-31 11:59 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0044_auto_20160531_1150'), + ('projects', '0042_auto_20160525_0911'), + ] + + operations = [ + ] diff --git a/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py b/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py new file mode 100644 index 000000000..3ab88e8af --- /dev/null +++ b/taiga/projects/migrations/0046_triggers_to_update_tags_colors.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-07 06:19 +from __future__ import unicode_literals + +from django.db import migrations +from django.db import connection + + +def _get_agg_array_agg_mult(): + pg_version = connection.cursor().connection.server_version + + if pg_version < 140000: # PostgreSQL < 14.0 + return """ + DROP AGGREGATE IF EXISTS array_agg_mult (anyarray); + CREATE AGGREGATE array_agg_mult (anyarray) ( + SFUNC = array_cat + ,STYPE = anyarray + ,INITCOND = '{}' + ); + """ + else: # PostgreSQL >= 14.0 + return """ + DROP AGGREGATE IF EXISTS array_agg_mult (anycompatiblearray); + CREATE AGGREGATE array_agg_mult (anycompatiblearray) ( + SFUNC = array_cat + ,STYPE = anycompatiblearray + ,INITCOND = '{}' + ); + """ + + +def _delete_unused_function(): + pg_version = connection.cursor().connection.server_version + + if pg_version < 140000: # PostgreSQL < 14.0 + return """ + DROP AGGREGATE IF EXISTS array_agg_mult (anyarray); + """ + else: # PostgreSQL >= 14.0 + return """ + DROP AGGREGATE IF EXISTS array_agg_mult (anycompatiblearray); + """ + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0045_merge'), + ('userstories', '0011_userstory_tribe_gig'), + ('tasks', '0009_auto_20151104_1131'), + ('issues', '0006_remove_issue_watchers'), + ] + + operations = [ + ### Error: + ### psycopg2.errors.UndefinedFunction: function array_cat(anyarray, anyarray) does not exist + + + # Function: Reduce a multidimensional array only on its first level + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION public.reduce_dim(anyarray) + RETURNS SETOF anyarray + AS $function$ + DECLARE + s $1%TYPE; + BEGIN + IF $1 = '{}' THEN + RETURN; + END IF; + FOREACH s SLICE 1 IN ARRAY $1 LOOP + RETURN NEXT s; + END LOOP; + RETURN; + END; + $function$ + LANGUAGE plpgsql IMMUTABLE; + """ + ), + # Function: aggregates multi dimensional arrays + migrations.RunSQL(_get_agg_array_agg_mult()), + # Function: array_distinct + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION array_distinct(anyarray) + RETURNS anyarray AS $$ + SELECT ARRAY(SELECT DISTINCT unnest($1)) + $$ LANGUAGE sql; + """ + ), + # Rebuild the color tags so it's consisten in any project + migrations.RunSQL( + """ + WITH + tags_colors AS ( + SELECT id project_id, reduce_dim(tags_colors) tags_colors + FROM projects_project + WHERE tags_colors != '{}' + ), + tags AS ( + SELECT unnest(tags) tag, NULL color, project_id FROM userstories_userstory + UNION + SELECT unnest(tags) tag, NULL color, project_id FROM tasks_task + UNION + SELECT unnest(tags) tag, NULL color, project_id FROM issues_issue + UNION + SELECT unnest(tags) tag, NULL color, id project_id FROM projects_project + ), + rebuilt_tags_colors AS ( + SELECT tags.project_id project_id, + array_agg_mult(ARRAY[[tags.tag, tags_colors.tags_colors[2]]]) tags_colors + FROM tags + LEFT JOIN tags_colors ON + tags_colors.project_id = tags.project_id AND + tags_colors[1] = tags.tag + GROUP BY tags.project_id + ) + UPDATE projects_project + SET tags_colors = rebuilt_tags_colors.tags_colors + FROM rebuilt_tags_colors + WHERE rebuilt_tags_colors.project_id = projects_project.id; + """ + ), + # Trigger for auto updating projects_project.tags_colors + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION update_project_tags_colors() + RETURNS trigger AS $update_project_tags_colors$ + DECLARE + tags text[]; + project_tags_colors text[]; + tag_color text[]; + project_tags text[]; + tag text; + project_id integer; + BEGIN + tags := NEW.tags::text[]; + project_id := NEW.project_id::integer; + project_tags := '{}'; + + -- Read project tags_colors into project_tags_colors + SELECT projects_project.tags_colors INTO project_tags_colors + FROM projects_project + WHERE id = project_id; + + -- Extract just the project tags to project_tags_colors + IF project_tags_colors != ARRAY[]::text[] THEN + FOREACH tag_color SLICE 1 in ARRAY project_tags_colors + LOOP + project_tags := array_append(project_tags, tag_color[1]); + END LOOP; + END IF; + + -- Add to project_tags_colors the new tags + IF tags IS NOT NULL THEN + FOREACH tag in ARRAY tags + LOOP + IF tag != ALL(project_tags) THEN + project_tags_colors := array_cat(project_tags_colors, + ARRAY[ARRAY[tag, NULL]]); + END IF; + END LOOP; + END IF; + + -- Save the result in the tags_colors column + UPDATE projects_project + SET tags_colors = project_tags_colors + WHERE id = project_id; + + RETURN NULL; + END; $update_project_tags_colors$ + LANGUAGE plpgsql; + """ + ), + + # Execute trigger after user_story update + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_userstory_update ON userstories_userstory; + CREATE TRIGGER update_project_tags_colors_on_userstory_update + AFTER UPDATE ON userstories_userstory + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after user_story insert + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_userstory_insert ON userstories_userstory; + CREATE TRIGGER update_project_tags_colors_on_userstory_insert + AFTER INSERT ON userstories_userstory + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after task update + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_task_update ON tasks_task; + CREATE TRIGGER update_project_tags_colors_on_task_update + AFTER UPDATE ON tasks_task + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after task insert + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_task_insert ON tasks_task; + CREATE TRIGGER update_project_tags_colors_on_task_insert + AFTER INSERT ON tasks_task + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after issue update + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_issue_update ON issues_issue; + CREATE TRIGGER update_project_tags_colors_on_issue_update + AFTER UPDATE ON issues_issue + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Execute trigger after issue insert + migrations.RunSQL( + """ + DROP TRIGGER IF EXISTS update_project_tags_colors_on_issue_insert ON issues_issue; + CREATE TRIGGER update_project_tags_colors_on_issue_insert + AFTER INSERT ON issues_issue + FOR EACH ROW EXECUTE PROCEDURE update_project_tags_colors(); + """ + ), + # Delete unneded function + migrations.RunSQL(_delete_unused_function()), + ] diff --git a/taiga/projects/migrations/0047_auto_20160614_1201.py b/taiga/projects/migrations/0047_auto_20160614_1201.py new file mode 100644 index 000000000..9a8637ee8 --- /dev/null +++ b/taiga/projects/migrations/0047_auto_20160614_1201.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0046_triggers_to_update_tags_colors'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='anon_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('view_us', 'View user stories'), ('view_tasks', 'View tasks'), ('view_issues', 'View issues'), ('view_wiki_pages', 'View wiki pages'), ('view_wiki_links', 'View wiki links')]), blank=True, default=list, null=True, size=None, verbose_name='anonymous permissions'), + ), + migrations.AlterField( + model_name='project', + name='public_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='user permissions'), + ), + migrations.AlterField( + model_name='project', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=list, null=True, size=None, verbose_name='tags'), + ), + migrations.AlterField( + model_name='project', + name='tags_colors', + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), size=2), blank=True, default=list, null=True, size=None, verbose_name='tags colors'), + ), + ] diff --git a/taiga/projects/migrations/0048_auto_20160615_1508.py b/taiga/projects/migrations/0048_auto_20160615_1508.py new file mode 100644 index 000000000..818d02999 --- /dev/null +++ b/taiga/projects/migrations/0048_auto_20160615_1508.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-15 15:08 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0047_auto_20160614_1201'), + ] + + operations = [ + migrations.AlterModelOptions( + name='projecttemplate', + options={'ordering': ['order', 'name'], 'verbose_name': 'project template', 'verbose_name_plural': 'project templates'}, + ), + migrations.AddField( + model_name='projecttemplate', + name='order', + field=models.IntegerField(default=10000, verbose_name='user order'), + ), + ] diff --git a/taiga/projects/migrations/0049_auto_20160629_1443.py b/taiga/projects/migrations/0049_auto_20160629_1443.py new file mode 100644 index 000000000..7173439ea --- /dev/null +++ b/taiga/projects/migrations/0049_auto_20160629_1443.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-29 14:43 +from __future__ import unicode_literals + +import taiga.base.db.models.fields +from django.db import migrations, models +import django.db.models.deletion +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0048_auto_20160615_1508'), + ] + + operations = [ + migrations.CreateModel( + name='EpicStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('slug', models.SlugField(blank=True, max_length=255, verbose_name='slug')), + ('order', models.IntegerField(default=10, verbose_name='order')), + ('is_closed', models.BooleanField(default=False, verbose_name='is closed')), + ('color', models.CharField(default='#999999', max_length=20, verbose_name='color')), + ], + options={ + 'verbose_name_plural': 'epic statuses', + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'epic status', + }, + ), + migrations.AlterModelOptions( + name='issuestatus', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'issue status', 'verbose_name_plural': 'issue statuses'}, + ), + migrations.AlterModelOptions( + name='issuetype', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'issue type', 'verbose_name_plural': 'issue types'}, + ), + migrations.AlterModelOptions( + name='membership', + options={'ordering': ['project', 'user__full_name', 'user__username', 'user__email', 'email'], 'verbose_name': 'membership', 'verbose_name_plural': 'memberships'}, + ), + migrations.AlterModelOptions( + name='points', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'points', 'verbose_name_plural': 'points'}, + ), + migrations.AlterModelOptions( + name='priority', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'priority', 'verbose_name_plural': 'priorities'}, + ), + migrations.AlterModelOptions( + name='severity', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'severity', 'verbose_name_plural': 'severities'}, + ), + migrations.AlterModelOptions( + name='taskstatus', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'task status', 'verbose_name_plural': 'task statuses'}, + ), + migrations.AlterModelOptions( + name='userstorystatus', + options={'ordering': ['project', 'order', 'name'], 'verbose_name': 'user story status', 'verbose_name_plural': 'user story statuses'}, + ), + migrations.AddField( + model_name='project', + name='is_epics_activated', + field=models.BooleanField(default=False, verbose_name='active epics panel'), + ), + migrations.AddField( + model_name='projecttemplate', + name='epic_statuses', + field=taiga.base.db.models.fields.JSONField(blank=True, null=True, verbose_name='epic statuses'), + ), + migrations.AddField( + model_name='projecttemplate', + name='is_epics_activated', + field=models.BooleanField(default=False, verbose_name='active epics panel'), + ), + migrations.AlterField( + model_name='project', + name='anon_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('view_epics', 'View epic'), ('view_us', 'View user stories'), ('view_tasks', 'View tasks'), ('view_issues', 'View issues'), ('view_wiki_pages', 'View wiki pages'), ('view_wiki_links', 'View wiki links')]), blank=True, default=list, null=True, size=None, verbose_name='anonymous permissions'), + ), + migrations.AlterField( + model_name='project', + name='public_permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_epics', 'View epic'), ('add_epic', 'Add epic'), ('modify_epic', 'Modify epic'), ('comment_epic', 'Comment epic'), ('delete_epic', 'Delete epic'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='user permissions'), + ), + migrations.AddField( + model_name='epicstatus', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='epic_statuses', to='projects.Project', verbose_name='project'), + ), + migrations.AddField( + model_name='project', + name='default_epic_status', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='projects.EpicStatus', verbose_name='default epic status'), + ), + migrations.AlterUniqueTogether( + name='epicstatus', + unique_together=set([('project', 'slug'), ('project', 'name')]), + ), + ] diff --git a/taiga/projects/migrations/0050_project_epics_csv_uuid.py b/taiga/projects/migrations/0050_project_epics_csv_uuid.py new file mode 100644 index 000000000..50767c358 --- /dev/null +++ b/taiga/projects/migrations/0050_project_epics_csv_uuid.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-07-20 17:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0049_auto_20160629_1443'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='epics_csv_uuid', + field=models.CharField(blank=True, db_index=True, default=None, editable=False, max_length=32, null=True), + ), + ] diff --git a/taiga/projects/migrations/0051_auto_20160729_0802.py b/taiga/projects/migrations/0051_auto_20160729_0802.py new file mode 100644 index 000000000..2a92cbf7a --- /dev/null +++ b/taiga/projects/migrations/0051_auto_20160729_0802.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-07-29 08:02 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0050_project_epics_csv_uuid'), + ] + + operations = [ + migrations.AlterModelOptions( + name='project', + options={'ordering': ['name', 'id'], 'verbose_name': 'project', 'verbose_name_plural': 'projects'}, + ), + ] diff --git a/taiga/projects/migrations/0052_epic_status.py b/taiga/projects/migrations/0052_epic_status.py new file mode 100644 index 000000000..1d0e1293d --- /dev/null +++ b/taiga/projects/migrations/0052_epic_status.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-08-25 10:19 +from __future__ import unicode_literals + +from django.db import connection, migrations, models + +def update_epic_status(apps, schema_editor): + Project = apps.get_model("projects", "Project") + project_ids = Project.objects.filter(default_epic_status__isnull=True).values_list("id", flat=True) + if not project_ids: + return + + values_sql = [] + for project_id in project_ids: + values_sql.append("('New', 'new', 1, false, '#999999', {project_id})".format(project_id=project_id)) + values_sql.append("('Ready', 'ready', 2, false, '#ff8a84', {project_id})".format(project_id=project_id)) + values_sql.append("('In progress', 'in-progress', 3, false, '#ff9900', {project_id})".format(project_id=project_id)) + values_sql.append("('Ready for test', 'ready-for-test', 4, false, '#fcc000', {project_id})".format(project_id=project_id)) + values_sql.append("('Done', 'done', 5, true, '#669900', {project_id})".format(project_id=project_id)) + + sql = """ + INSERT INTO projects_epicstatus (name, slug, "order", is_closed, color, project_id) + VALUES + {values}; + """.format(values=','.join(values_sql)) + cursor = connection.cursor() + cursor.execute(sql) + + +def update_default_epic_status(apps, schema_editor): + sql = """ + UPDATE projects_project + SET default_epic_status_id = projects_epicstatus.id + FROM projects_epicstatus + WHERE + projects_project.default_epic_status_id IS NULL + AND + projects_epicstatus.order = 1 + AND + projects_epicstatus.project_id = projects_project.id; + """ + cursor = connection.cursor() + cursor.execute(sql) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0051_auto_20160729_0802'), + ] + + operations = [ + migrations.RunPython(update_epic_status), + migrations.RunPython(update_default_epic_status) + ] diff --git a/taiga/projects/migrations/0053_auto_20160927_0741.py b/taiga/projects/migrations/0053_auto_20160927_0741.py new file mode 100644 index 000000000..8351d4a7d --- /dev/null +++ b/taiga/projects/migrations/0053_auto_20160927_0741.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-09-27 07:41 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0052_epic_status'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='creation_template', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='projects.ProjectTemplate', verbose_name='creation template'), + ), + ] diff --git a/taiga/projects/migrations/0054_auto_20160928_0540.py b/taiga/projects/migrations/0054_auto_20160928_0540.py new file mode 100644 index 000000000..ab1a8af1b --- /dev/null +++ b/taiga/projects/migrations/0054_auto_20160928_0540.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-09-28 05:40 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.base.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0053_auto_20160927_0741'), + ] + + operations = [ + migrations.AlterField( + model_name='membership', + name='user_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='user order'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='user order'), + ), + ] diff --git a/taiga/projects/migrations/0055_json_to_jsonb.py b/taiga/projects/migrations/0055_json_to_jsonb.py new file mode 100644 index 000000000..b4c99dc87 --- /dev/null +++ b/taiga/projects/migrations/0055_json_to_jsonb.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.2 on 2016-10-26 11:34 +from __future__ import unicode_literals + +from django.db import migrations +from django.contrib.postgres.fields import JSONField + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0054_auto_20160928_0540'), + ] + + operations = [ + migrations.RunSQL( + """ + ALTER TABLE "projects_projectmodulesconfig" + ALTER COLUMN "config" + TYPE jsonb + USING regexp_replace("config"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """, + reverse_sql=migrations.RunSQL.noop + ), + migrations.RunSQL( + """ + ALTER TABLE "projects_projecttemplate" + ALTER COLUMN "roles" + TYPE jsonb + USING regexp_replace("roles"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "default_options" + TYPE jsonb + USING regexp_replace("default_options"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "epic_statuses" + TYPE jsonb + USING regexp_replace("epic_statuses"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "us_statuses" + TYPE jsonb + USING regexp_replace("us_statuses"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "points" + TYPE jsonb + USING regexp_replace("points"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "task_statuses" + TYPE jsonb + USING regexp_replace("task_statuses"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "issue_statuses" + TYPE jsonb + USING regexp_replace("issue_statuses"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "issue_types" + TYPE jsonb + USING regexp_replace("issue_types"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "priorities" + TYPE jsonb + USING regexp_replace("priorities"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "severities" + TYPE jsonb + USING regexp_replace("severities"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """, + reverse_sql=migrations.RunSQL.noop + ), + ] diff --git a/taiga/projects/migrations/0056_auto_20161110_1518.py b/taiga/projects/migrations/0056_auto_20161110_1518.py new file mode 100644 index 000000000..df9340d7a --- /dev/null +++ b/taiga/projects/migrations/0056_auto_20161110_1518.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-11-10 15:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0055_json_to_jsonb'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='is_contact_activated', + field=models.BooleanField(default=True, verbose_name='active contact'), + ), + migrations.AddField( + model_name='projecttemplate', + name='is_contact_activated', + field=models.BooleanField(default=True, verbose_name='active contact'), + ), + ] diff --git a/taiga/projects/migrations/0057_auto_20161129_0945.py b/taiga/projects/migrations/0057_auto_20161129_0945.py new file mode 100644 index 000000000..926003c99 --- /dev/null +++ b/taiga/projects/migrations/0057_auto_20161129_0945.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.3 on 2016-11-29 09:45 +from __future__ import unicode_literals + +from django.db import migrations + + +DROP_INDEX = """ + DROP INDEX IF EXISTS projects_project_textquery_idx; +""" + + +# NOTE: This index is needed by taiga.projects.filters.QFilter +CREATE_INDEX = """ + CREATE INDEX projects_project_textquery_idx + ON projects_project + USING gin((setweight(to_tsvector('simple', + coalesce(projects_project.name, '')), 'A') || + setweight(to_tsvector('simple', + coalesce(inmutable_array_to_string(projects_project.tags), '')), 'B') || + setweight(to_tsvector('simple', + coalesce(projects_project.description, '')), 'C'))); +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0056_auto_20161110_1518'), + ] + + operations = [ + migrations.RunSQL([DROP_INDEX, CREATE_INDEX], + [DROP_INDEX]), + ] diff --git a/taiga/projects/migrations/0058_auto_20161215_1347.py b/taiga/projects/migrations/0058_auto_20161215_1347.py new file mode 100644 index 000000000..ee197bd0d --- /dev/null +++ b/taiga/projects/migrations/0058_auto_20161215_1347.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.3 on 2016-12-15 13:47 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +import django.core.serializers.json +from django.db import migrations, models +import taiga.base.db.models.fields.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0057_auto_20161129_0945'), + ] + + operations = [ + migrations.AddField( + model_name='projecttemplate', + name='epic_custom_attributes', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='epic custom attributes'), + ), + migrations.AddField( + model_name='projecttemplate', + name='is_looking_for_people', + field=models.BooleanField(default=False, verbose_name='is looking for people'), + ), + migrations.AddField( + model_name='projecttemplate', + name='issue_custom_attributes', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='issue custom attributes'), + ), + migrations.AddField( + model_name='projecttemplate', + name='looking_for_people_note', + field=models.TextField(blank=True, default='', verbose_name='loking for people note'), + ), + migrations.AddField( + model_name='projecttemplate', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=list, null=True, size=None, verbose_name='tags'), + ), + migrations.AddField( + model_name='projecttemplate', + name='tags_colors', + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), size=2), blank=True, default=list, null=True, size=None, verbose_name='tags colors'), + ), + migrations.AddField( + model_name='projecttemplate', + name='task_custom_attributes', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='task custom attributes'), + ), + migrations.AddField( + model_name='projecttemplate', + name='us_custom_attributes', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='us custom attributes'), + ), + ] diff --git a/taiga/projects/migrations/0059_auto_20170116_1633.py b/taiga/projects/migrations/0059_auto_20170116_1633.py new file mode 100644 index 000000000..db690f1c5 --- /dev/null +++ b/taiga/projects/migrations/0059_auto_20170116_1633.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.4 on 2017-01-16 16:33 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0058_auto_20161215_1347'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='looking_for_people_note', + field=models.TextField(blank=True, default='', verbose_name='looking for people note'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='looking_for_people_note', + field=models.TextField(blank=True, default='', verbose_name='looking for people note'), + ), + ] diff --git a/taiga/projects/migrations/0060_auto_20180614_1338.py b/taiga/projects/migrations/0060_auto_20180614_1338.py new file mode 100644 index 000000000..054414131 --- /dev/null +++ b/taiga/projects/migrations/0060_auto_20180614_1338.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.2 on 2018-06-14 13:38 +from __future__ import unicode_literals + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taiga.base.db.models.fields.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0059_auto_20170116_1633'), + ] + + operations = [ + migrations.CreateModel( + name='IssueDueDate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(default=10, verbose_name='order')), + ('by_default', models.BooleanField(default=False, verbose_name='by default')), + ('color', models.CharField(default='#999999', max_length=20, verbose_name='color')), + ('days_to_due', models.IntegerField(blank=True, default=None, null=True, verbose_name='days to due')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_duedates', to='projects.Project', verbose_name='project')), + ], + options={ + 'verbose_name': 'issue due date', + 'verbose_name_plural': 'issue due dates', + 'ordering': ['project', 'order', 'name'], + }, + ), + migrations.CreateModel( + name='TaskDueDate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(default=10, verbose_name='order')), + ('by_default', models.BooleanField(default=False, verbose_name='by default')), + ('color', models.CharField(default='#999999', max_length=20, verbose_name='color')), + ('days_to_due', models.IntegerField(blank=True, default=None, null=True, verbose_name='days to due')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_duedates', to='projects.Project', verbose_name='project')), + ], + options={ + 'verbose_name': 'task due date', + 'verbose_name_plural': 'task due dates', + 'ordering': ['project', 'order', 'name'], + }, + ), + migrations.CreateModel( + name='UserStoryDueDate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('order', models.IntegerField(default=10, verbose_name='order')), + ('by_default', models.BooleanField(default=False, verbose_name='by default')), + ('color', models.CharField(default='#999999', max_length=20, verbose_name='color')), + ('days_to_due', models.IntegerField(blank=True, default=None, null=True, verbose_name='days to due')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='us_duedates', to='projects.Project', verbose_name='project')), + ], + options={ + 'verbose_name': 'user story due date', + 'verbose_name_plural': 'user story due dates', + 'ordering': ['project', 'order', 'name'], + }, + ), + migrations.AddField( + model_name='projecttemplate', + name='issue_duedates', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='issue duedates'), + ), + migrations.AddField( + model_name='projecttemplate', + name='task_duedates', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='task duedates'), + ), + migrations.AddField( + model_name='projecttemplate', + name='us_duedates', + field=taiga.base.db.models.fields.json.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, verbose_name='us duedates'), + ), + migrations.AlterUniqueTogether( + name='issuestatus', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='userstoryduedate', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='taskduedate', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='issueduedate', + unique_together=set([('project', 'name')]), + ), + ] diff --git a/taiga/projects/migrations/0061_auto_20180918_1355.py b/taiga/projects/migrations/0061_auto_20180918_1355.py new file mode 100644 index 000000000..623fef312 --- /dev/null +++ b/taiga/projects/migrations/0061_auto_20180918_1355.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# 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')]), + ), + ] diff --git a/taiga/projects/migrations/0062_auto_20190826_0920.py b/taiga/projects/migrations/0062_auto_20190826_0920.py new file mode 100644 index 000000000..3e94b40e1 --- /dev/null +++ b/taiga/projects/migrations/0062_auto_20190826_0920.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.22 on 2019-08-26 09:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0061_auto_20180918_1355'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='videoconferences', + field=models.CharField(blank=True, choices=[('whereby-com', 'Whereby.com'), ('jitsi', 'Jitsi'), ('custom', 'Custom'), ('talky', 'Talky')], max_length=250, null=True, verbose_name='videoconference system'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='videoconferences', + field=models.CharField(blank=True, choices=[('whereby-com', 'Whereby.com'), ('jitsi', 'Jitsi'), ('custom', 'Custom'), ('talky', 'Talky')], max_length=250, null=True, verbose_name='videoconference system'), + ), + ] diff --git a/taiga/projects/migrations/0063_auto_20200615_0811.py b/taiga/projects/migrations/0063_auto_20200615_0811.py new file mode 100644 index 000000000..c746d3b03 --- /dev/null +++ b/taiga/projects/migrations/0063_auto_20200615_0811.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-15 08:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0062_auto_20190826_0920'), + ] + + operations = [ + migrations.AlterField( + model_name='epicstatus', + name='is_closed', + field=models.BooleanField(blank=True, default=False, verbose_name='is closed'), + ), + migrations.AlterField( + model_name='issueduedate', + name='by_default', + field=models.BooleanField(blank=True, default=False, verbose_name='by default'), + ), + migrations.AlterField( + model_name='issuestatus', + name='is_closed', + field=models.BooleanField(blank=True, default=False, verbose_name='is closed'), + ), + migrations.AlterField( + model_name='project', + name='is_backlog_activated', + field=models.BooleanField(blank=True, default=True, verbose_name='active backlog panel'), + ), + migrations.AlterField( + model_name='project', + name='is_contact_activated', + field=models.BooleanField(blank=True, default=True, verbose_name='active contact'), + ), + migrations.AlterField( + model_name='project', + name='is_epics_activated', + field=models.BooleanField(blank=True, default=False, verbose_name='active epics panel'), + ), + migrations.AlterField( + model_name='project', + name='is_featured', + field=models.BooleanField(blank=True, default=False, verbose_name='is featured'), + ), + migrations.AlterField( + model_name='project', + name='is_issues_activated', + field=models.BooleanField(blank=True, default=True, verbose_name='active issues panel'), + ), + migrations.AlterField( + model_name='project', + name='is_kanban_activated', + field=models.BooleanField(blank=True, default=False, verbose_name='active kanban panel'), + ), + migrations.AlterField( + model_name='project', + name='is_looking_for_people', + field=models.BooleanField(blank=True, default=False, verbose_name='is looking for people'), + ), + migrations.AlterField( + model_name='project', + name='is_private', + field=models.BooleanField(blank=True, default=True, verbose_name='is private'), + ), + migrations.AlterField( + model_name='project', + name='is_wiki_activated', + field=models.BooleanField(blank=True, default=True, verbose_name='active wiki panel'), + ), + migrations.AlterField( + model_name='project', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_projects', to=settings.AUTH_USER_MODEL, verbose_name='owner'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='is_backlog_activated', + field=models.BooleanField(blank=True, default=True, verbose_name='active backlog panel'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='is_contact_activated', + field=models.BooleanField(blank=True, default=True, verbose_name='active contact'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='is_epics_activated', + field=models.BooleanField(blank=True, default=False, verbose_name='active epics panel'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='is_issues_activated', + field=models.BooleanField(blank=True, default=True, verbose_name='active issues panel'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='is_kanban_activated', + field=models.BooleanField(blank=True, default=False, verbose_name='active kanban panel'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='is_looking_for_people', + field=models.BooleanField(blank=True, default=False, verbose_name='is looking for people'), + ), + migrations.AlterField( + model_name='projecttemplate', + name='is_wiki_activated', + field=models.BooleanField(blank=True, default=True, verbose_name='active wiki panel'), + ), + migrations.AlterField( + model_name='taskduedate', + name='by_default', + field=models.BooleanField(blank=True, default=False, verbose_name='by default'), + ), + migrations.AlterField( + model_name='taskstatus', + name='is_closed', + field=models.BooleanField(blank=True, default=False, verbose_name='is closed'), + ), + migrations.AlterField( + model_name='userstoryduedate', + name='by_default', + field=models.BooleanField(blank=True, default=False, verbose_name='by default'), + ), + migrations.AlterField( + model_name='userstorystatus', + name='is_archived', + field=models.BooleanField(blank=True, default=False, verbose_name='is archived'), + ), + migrations.AlterField( + model_name='userstorystatus', + name='is_closed', + field=models.BooleanField(blank=True, default=False, verbose_name='is closed'), + ), + ] diff --git a/taiga/projects/migrations/0064_swimlane.py b/taiga/projects/migrations/0064_swimlane.py new file mode 100644 index 000000000..05f9cba2e --- /dev/null +++ b/taiga/projects/migrations/0064_swimlane.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.15 on 2020-10-07 16:04 + +from django.db import migrations, models +import django.db.models.deletion +import taiga.base.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0063_auto_20200615_0811'), + ] + + operations = [ + migrations.CreateModel( + name='Swimlane', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField(verbose_name='name')), + ('order', models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='order')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='swimlanes', to='projects.Project', verbose_name='project')), + ], + options={ + 'verbose_name': 'swimlane', + 'verbose_name_plural': 'swimlanes', + 'ordering': ['project', 'order', 'name'], + }, + ), + ] diff --git a/taiga/projects/migrations/0065_swimlaneuserstorystatus.py b/taiga/projects/migrations/0065_swimlaneuserstorystatus.py new file mode 100644 index 000000000..d18965f83 --- /dev/null +++ b/taiga/projects/migrations/0065_swimlaneuserstorystatus.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.14 on 2020-11-11 17:18 + +from django.db import migrations, models +import django.db.models.deletion + + +def create_swimlane_userstory_statuses_for_existing_swimlanes(apps, schema_editor): + Project = apps.get_model("projects", "Project") + SwimlaneUserStoryStatus = apps.get_model("projects", "SwimlaneUserStoryStatus") + + projects = Project.objects.annotate(count=models.Count('swimlanes')).filter(count__gt=0) + objects = [] + for project in projects: + copy_from_main_status = project.swimlanes.all().count() == 1 + for swimlane in project.swimlanes.all(): + objects += [ + SwimlaneUserStoryStatus( + swimlane=swimlane, + status=status, + wip_limit=status.wip_limit if copy_from_main_status else 0 + ) + for status in project.us_statuses.all()] + + SwimlaneUserStoryStatus.objects.bulk_create(objects) + + +def empty_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0064_swimlane'), + ] + + operations = [ + migrations.CreateModel( + name='SwimlaneUserStoryStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('wip_limit', models.IntegerField(blank=True, default=None, null=True, verbose_name='work in progress limit')), + ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='swimlane_statuses', to='projects.UserStoryStatus', verbose_name='user story status')), + ('swimlane', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='projects.Swimlane', verbose_name='status')), + ], + options={ + 'verbose_name': 'swimlane user story status', + 'verbose_name_plural': 'swimlane user story statuses', + 'ordering': ['swimlane', 'status', 'id'], + 'unique_together': {('swimlane', 'status')}, + }, + ), + migrations.RunPython(create_swimlane_userstory_statuses_for_existing_swimlanes, empty_reverse), + ] diff --git a/taiga/projects/migrations/0066_project_default_swimlane.py b/taiga/projects/migrations/0066_project_default_swimlane.py new file mode 100644 index 000000000..0710fca61 --- /dev/null +++ b/taiga/projects/migrations/0066_project_default_swimlane.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.14 on 2020-11-23 16:52 + +from django.db import migrations, models +import django.db.models.deletion + + +def set_first_swimlane_as_default(apps, schema_editor): + Project = apps.get_model("projects", "Project") + + projects = Project.objects.annotate(count=models.Count('swimlanes')).filter(count__gt=0) + for project in projects: + project.default_swimlane = project.swimlanes.order_by('order').first() + project.save() + + +def empty_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0065_swimlaneuserstorystatus'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='default_swimlane', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='projects.Swimlane', verbose_name='default swimlane'), + ), + migrations.RunPython(set_first_swimlane_as_default, empty_reverse), + ] diff --git a/taiga/projects/migrations/0067_auto_20201230_1237.py b/taiga/projects/migrations/0067_auto_20201230_1237.py new file mode 100644 index 000000000..618adcaa9 --- /dev/null +++ b/taiga/projects/migrations/0067_auto_20201230_1237.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.17 on 2020-12-30 12:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0066_project_default_swimlane'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='swimlane', + unique_together={('project', 'name')}, + ), + ] diff --git a/taiga/projects/migrations/__init__.py b/taiga/projects/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/milestones/__init__.py b/taiga/projects/milestones/__init__.py new file mode 100644 index 000000000..513092d70 --- /dev/null +++ b/taiga/projects/milestones/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# diff --git a/taiga/projects/milestones/admin.py b/taiga/projects/milestones/admin.py new file mode 100644 index 000000000..0b59896c6 --- /dev/null +++ b/taiga/projects/milestones/admin.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + +from . import models + + +class MilestoneInline(admin.TabularInline): + model = models.Milestone + extra = 0 + + def get_formset(self, request, obj=None, **kwargs): + # Hack! Hook parent obj just in time to use in formfield_for_manytomany + self.parent_obj = obj + return super(MilestoneInline, self).get_formset(request, obj, **kwargs) + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if (db_field.name in ["owner"]): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.parent_obj) + + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +class MilestoneAdmin(admin.ModelAdmin): + list_display = ["name", "project", "owner", "closed", "estimated_start", + "estimated_finish"] + list_display_links = list_display + readonly_fields = ["owner"] + inlines = [WatchedInline, VoteInline] + search_fields = ["name", "id"] + raw_id_fields = ["project"] + + +admin.site.register(models.Milestone, MilestoneAdmin) diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py new file mode 100644 index 000000000..0d412d109 --- /dev/null +++ b/taiga/projects/milestones/api.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps + +from taiga.base import filters +from taiga.base import response +from taiga.base.decorators import detail_route +from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelListViewSet +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.api.utils import get_object_or_error +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 +from . import utils as milestones_utils + +from django_pglocks import advisory_lock +import datetime + + +class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, + BlockedByProjectMixin, ModelCrudViewSet): + serializer_class = serializers.MilestoneSerializer + validator_class = validators.MilestoneValidator + permission_classes = (permissions.MilestonePermission,) + filter_backends = ( + filters.CanViewMilestonesFilterBackend, + filters.CreatedDateFilter, + filters.ModifiedDateFilter, + filters.EstimatedStartFilter, + filters.EstimatedFinishFilter, + ) + filter_fields = ( + "project", + "project__slug", + "closed" + ) + order_by_fields = ("project", + "name", + "estimated_start", + "estimated_finish", + "closed", + "created_date") + queryset = models.Milestone.objects.all() + + def create(self, request, *args, **kwargs): + project_id = request.DATA.get("project", 0) + with advisory_lock("milestone-creation-{}".format(project_id)): + return super().create(request, *args, **kwargs) + + def list(self, request, *args, **kwargs): + res = super().list(request, *args, **kwargs) + self._add_taiga_info_headers() + return res + + def _add_taiga_info_headers(self): + try: + project_id = int(self.request.QUERY_PARAMS.get("project", None)) + project_model = apps.get_model("projects", "Project") + project = get_object_or_none(project_model, id=project_id) + except TypeError: + project = None + + if project: + opened_milestones = project.milestones.filter(closed=False).count() + closed_milestones = project.milestones.filter(closed=True).count() + + self.headers["Taiga-Info-Total-Opened-Milestones"] = opened_milestones + self.headers["Taiga-Info-Total-Closed-Milestones"] = closed_milestones + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.select_related("project", "owner") + qs = milestones_utils.attach_extra_info(qs, user=self.request.user) + qs = qs.order_by("-estimated_start") + return qs + + def pre_save(self, obj): + if not obj.id: + obj.owner = self.request.user + + super().pre_save(obj) + + @detail_route(methods=['get']) + def stats(self, request, pk=None): + milestone = get_object_or_error(models.Milestone, request.user, pk=pk) + + self.check_permissions(request, "stats", milestone) + + total_points = milestone.total_points + milestone_stats = { + 'name': milestone.name, + 'estimated_start': milestone.estimated_start, + 'estimated_finish': milestone.estimated_finish, + 'total_points': total_points, + 'completed_points': milestone.closed_points.values(), + 'total_userstories': milestone.cached_user_stories.count(), + 'completed_userstories': milestone.cached_user_stories.filter(is_closed=True).count(), + 'total_tasks': milestone.tasks.count(), + 'completed_tasks': milestone.tasks.filter(status__is_closed=True).count(), + 'iocaine_doses': milestone.tasks.filter(is_iocaine=True).count(), + 'days': [] + } + current_date = milestone.estimated_start + sumTotalPoints = sum(total_points.values()) + optimal_points = sumTotalPoints + milestone_days = (milestone.estimated_finish - milestone.estimated_start).days + optimal_points_per_day = sumTotalPoints / milestone_days if milestone_days else 0 + + while current_date <= milestone.estimated_finish: + milestone_stats['days'].append({ + 'day': current_date, + 'name': current_date.day, + 'open_points': sumTotalPoints - milestone.total_closed_points_by_date(current_date), + 'optimal_points': optimal_points, + }) + current_date = current_date + datetime.timedelta(days=1) + optimal_points -= optimal_points_per_day + + return response.Ok(milestone_stats) + + @detail_route(methods=["POST"]) + def move_userstories_to_sprint(self, request, pk=None, **kwargs): + milestone = get_object_or_error(models.Milestone, request.user, 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_error(Project, request.user, pk=data["project_id"]) + milestone_result = get_object_or_error(models.Milestone, request.user, 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_error(models.Milestone, request.user, 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_error(Project, request.user, pk=data["project_id"]) + milestone_result = get_object_or_error(models.Milestone, request.user, 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_error(models.Milestone, request.user, 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_error(Project, request.user, pk=data["project_id"]) + milestone_result = get_object_or_error(models.Milestone, request.user, 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,) + resource_model = models.Milestone diff --git a/taiga/projects/milestones/apps.py b/taiga/projects/milestones/apps.py new file mode 100644 index 000000000..25279b72a --- /dev/null +++ b/taiga/projects/milestones/apps.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.apps import AppConfig + + +class MilestonesAppConfig(AppConfig): + name = "taiga.projects.milestones" + verbose_name = "Milestones" + watched_types = ["milestones.milestone", ] diff --git a/taiga/projects/milestones/migrations/0001_initial.py b/taiga/projects/milestones/migrations/0001_initial.py new file mode 100644 index 000000000..ad7d8af23 --- /dev/null +++ b/taiga/projects/milestones/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0002_auto_20140903_0920'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Milestone', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, primary_key=True, auto_created=True)), + ('name', models.CharField(verbose_name='name', max_length=200, db_index=True)), + ('slug', models.SlugField(verbose_name='slug', blank=True, max_length=250)), + ('estimated_start', models.DateField(verbose_name='estimated start date')), + ('estimated_finish', models.DateField(verbose_name='estimated finish date')), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('closed', models.BooleanField(verbose_name='is closed', default=False)), + ('disponibility', models.FloatField(null=True, blank=True, verbose_name='disponibility', default=0.0)), + ('order', models.PositiveSmallIntegerField(verbose_name='order', default=1)), + ('owner', models.ForeignKey(null=True, blank=True, to=settings.AUTH_USER_MODEL, verbose_name='owner', related_name='owned_milestones', on_delete=models.SET_NULL)), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='milestones', on_delete=models.CASCADE)), + ('watchers', models.ManyToManyField(null=True, blank=True, related_name='milestones_milestone+', verbose_name='watchers', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'milestone', + 'verbose_name_plural': 'milestones', + 'ordering': ['project', 'created_date'], + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='milestone', + unique_together=set([('slug', 'project'), ('name', 'project')]), + ), + ] diff --git a/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py b/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py new file mode 100644 index 000000000..ff2e0b11a --- /dev/null +++ b/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import connection +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from taiga.base.utils.contenttypes import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes(verbosity=0) + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT milestone_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM milestones_milestone_watchers INNER JOIN milestones_milestone ON milestones_milestone_watchers.milestone_id = milestones_milestone.id""".format(content_type_id=ContentType.objects.get(model='milestone').id) + cursor = connection.cursor() + cursor.execute(sql) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('milestones', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='milestone', + name='watchers', + ), + ] diff --git a/taiga/projects/milestones/migrations/0003_auto_20200615_0811.py b/taiga/projects/milestones/migrations/0003_auto_20200615_0811.py new file mode 100644 index 000000000..0f9c74c33 --- /dev/null +++ b/taiga/projects/milestones/migrations/0003_auto_20200615_0811.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-15 08:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('milestones', '0002_remove_milestone_watchers'), + ] + + operations = [ + migrations.AlterField( + model_name='milestone', + name='closed', + field=models.BooleanField(blank=True, default=False, verbose_name='is closed'), + ), + ] diff --git a/taiga/projects/milestones/migrations/__init__.py b/taiga/projects/milestones/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/milestones/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/milestones/models.py b/taiga/projects/milestones/models.py new file mode 100644 index 000000000..b43b43f3f --- /dev/null +++ b/taiga/projects/milestones/models.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.db.models import Count +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.utils.functional import cached_property + +from taiga.base.utils.slug import slugify_uniquely +from taiga.base.utils.dicts import dict_sum +from taiga.projects.notifications.mixins import WatchedModelMixin + +import itertools +import datetime + + +class Milestone(WatchedModelMixin, models.Model): + name = models.CharField(max_length=200, db_index=True, null=False, blank=False, + verbose_name=_("name")) + # TODO: Change the unique restriction to a unique together with the project id + slug = models.SlugField(max_length=250, db_index=True, null=False, blank=True, + verbose_name=_("slug")) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + related_name="owned_milestones", + verbose_name=_("owner"), + on_delete=models.SET_NULL, + ) + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="milestones", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + estimated_start = models.DateField(verbose_name=_("estimated start date")) + estimated_finish = models.DateField(verbose_name=_("estimated finish date")) + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + closed = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is closed")) + disponibility = models.FloatField(default=0.0, null=True, blank=True, + verbose_name=_("disponibility")) + order = models.PositiveSmallIntegerField(default=1, null=False, blank=False, + verbose_name=_("order")) + _importing = None + _total_closed_points_by_date = None + + class Meta: + verbose_name = "milestone" + verbose_name_plural = "milestones" + ordering = ["project", "created_date"] + unique_together = [("name", "project"), ("slug", "project")] + + def __str__(self): + return self.name + + def __repr__(self): + return "".format(self.id) + + def clean(self): + # Don't allow draft entries to have a pub_date. + if self.estimated_start and self.estimated_finish and self.estimated_start > self.estimated_finish: + raise ValidationError(_('The estimated start must be previous to the estimated finish.')) + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + if not self.slug: + self.slug = slugify_uniquely(self.name, self.__class__) + super().save(*args, **kwargs) + + @cached_property + def cached_user_stories(self): + return (self.user_stories.prefetch_related("role_points", "role_points__points") + .annotate(num_tasks=Count("tasks"))) + + def _get_user_stories_points(self, user_stories): + role_points = [us.role_points.all() for us in user_stories] + flat_role_points = itertools.chain(*role_points) + flat_role_dicts = map(lambda x: {x.role_id: x.points.value if x.points.value else 0}, flat_role_points) + return dict_sum(*flat_role_dicts) + + @property + def total_points(self): + return self._get_user_stories_points( + [us for us in self.cached_user_stories] + ) + + @property + def closed_points(self): + return self._get_user_stories_points( + [us for us in self.cached_user_stories if us.is_closed] + ) + + def total_closed_points_by_date(self, date): + # Milestone instance will keep a cache of the total closed points by date + if self._total_closed_points_by_date is None: + self._total_closed_points_by_date = {} + + # We need to keep the milestone user stories indexed by id in a dict + user_stories = {} + for us in self.cached_user_stories: + us._total_us_points = sum(self._get_user_stories_points([us]).values()) + user_stories[us.id] = us + + tasks = self.tasks.\ + select_related("user_story").\ + exclude(finished_date__isnull=True).\ + exclude(user_story__isnull=True) + + # For each finished task we try to know the proporional part of points + # it represetnts from the user story and add it to the closed points + # for that date + # This calulation is the total user story points divided by its number of tasks + for task in tasks: + user_story = user_stories.get(task.user_story.id, None) + if user_story is None: + total_us_points = 0 + us_tasks_counter = 0 + else: + total_us_points = user_story._total_us_points + us_tasks_counter = user_story.num_tasks + + # If the task was finished before starting the sprint it needs + # to be included + finished_date = task.finished_date.date() + if finished_date < self.estimated_start: + finished_date = self.estimated_start + + points_by_date = self._total_closed_points_by_date.get(finished_date, 0) + if us_tasks_counter != 0: + points_by_date += total_us_points / us_tasks_counter + + self._total_closed_points_by_date[finished_date] = points_by_date + + for us in self.cached_user_stories: + if us.num_tasks > 0 or us.finish_date is None: + continue + finished_date = us.finish_date.date() + if finished_date < self.estimated_start: + finished_date = self.estimated_start + points_by_date = self._total_closed_points_by_date.get(finished_date, 0) + points_by_date += us._total_us_points + self._total_closed_points_by_date[finished_date] = points_by_date + + + # At this point self._total_closed_points_by_date keeps a dict where the + # finished date of the task is the key and the value is the increment of points + # We are transforming this dict of increments in an acumulation one including + # all the dates from the sprint + + acumulated_date_points = 0 + current_date = self.estimated_start + while current_date <= self.estimated_finish: + acumulated_date_points += self._total_closed_points_by_date.get(current_date, 0) + self._total_closed_points_by_date[current_date] = acumulated_date_points + current_date = current_date + datetime.timedelta(days=1) + + return self._total_closed_points_by_date.get(date, 0) diff --git a/taiga/projects/milestones/permissions.py b/taiga/projects/milestones/permissions.py new file mode 100644 index 000000000..e0ef3486e --- /dev/null +++ b/taiga/projects/milestones/permissions.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, + IsAuthenticated, IsProjectAdmin, AllowAny, + IsSuperUser) + + +class MilestonePermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_milestones') + create_perms = HasProjectPerm('add_milestone') + update_perms = HasProjectPerm('modify_milestone') + partial_update_perms = HasProjectPerm('modify_milestone') + destroy_perms = HasProjectPerm('delete_milestone') + list_perms = AllowAny() + 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): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_milestones') + list_perms = HasProjectPerm('view_milestones') diff --git a/taiga/projects/milestones/serializers.py b/taiga/projects/milestones/serializers.py new file mode 100644 index 000000000..0dbbc4596 --- /dev/null +++ b/taiga/projects/milestones/serializers.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.projects.userstories.serializers import UserStoryNestedSerializer +from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin + + +class MilestoneSerializer(ProjectExtraInfoSerializerMixin, + serializers.LightSerializer): + id = Field() + name = Field() + slug = Field() + owner = Field(attr="owner_id") + project = Field(attr="project_id") + estimated_start = Field() + estimated_finish = Field() + created_date = Field() + modified_date = Field() + closed = Field() + disponibility = Field() + order = Field() + user_stories = MethodField() + total_points = MethodField() + closed_points = MethodField() + + def get_user_stories(self, obj): + return UserStoryNestedSerializer(obj.user_stories.all(), many=True).data + + def get_total_points(self, obj): + assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute" + return obj.total_points_attr + + def get_closed_points(self, obj): + assert hasattr(obj, "closed_points_attr"), "instance must have a closed_points_attr attribute" + return obj.closed_points_attr diff --git a/taiga/projects/milestones/services.py b/taiga/projects/milestones/services.py new file mode 100644 index 000000000..d84d3075a --- /dev/null +++ b/taiga/projects/milestones/services.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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): + 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.filter(user_story__isnull=True)]) + 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) + tasks_check = (milestone.tasks.filter(user_story__isnull=True).count() > 0 + and all_tasks_closed and all_issues_closed and all_us_closed) + issues_check = (milestone.issues.all().count() > 0 + and all_issues_closed and all_tasks_closed and all_us_closed) + + return uss_check or issues_check or tasks_check + + +def close_milestone(milestone): + if not milestone.closed: + milestone.closed = True + milestone.save(update_fields=["closed",]) + +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': , 'order': }, ...] + """ + 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': , 'order': }, ...] + """ + 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': }, ...] + """ + 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 diff --git a/taiga/projects/milestones/signals.py b/taiga/projects/milestones/signals.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/milestones/signals.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/milestones/utils.py b/taiga/projects/milestones/utils.py new file mode 100644 index 000000000..bc63c1ab8 --- /dev/null +++ b/taiga/projects/milestones/utils.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.db.models import Prefetch + +from taiga.projects.userstories import utils as userstories_utils + + +def attach_total_points(queryset, as_field="total_points_attr"): + """Attach total of point values to each object of the queryset. + + :param queryset: A Django milestones queryset object. + :param as_field: Attach the points as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT SUM(projects_points.value) + FROM userstories_rolepoints + INNER JOIN userstories_userstory ON userstories_userstory.id = userstories_rolepoints.user_story_id + INNER JOIN projects_points ON userstories_rolepoints.points_id = projects_points.id + WHERE userstories_userstory.milestone_id = {tbl}.id""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_closed_points(queryset, as_field="closed_points_attr"): + """Attach total of closed point values to each object of the queryset. + + :param queryset: A Django milestones queryset object. + :param as_field: Attach the points as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT SUM(projects_points.value) + FROM userstories_rolepoints + INNER JOIN userstories_userstory ON userstories_userstory.id = userstories_rolepoints.user_story_id + INNER JOIN projects_points ON userstories_rolepoints.points_id = projects_points.id + WHERE userstories_userstory.milestone_id = {tbl}.id AND userstories_userstory.is_closed=True""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_extra_info(queryset, user=None): + # Userstories prefetching + UserStory = apps.get_model("userstories", "UserStory") + us_queryset = UserStory.objects.select_related("milestone", + "project", + "status", + "owner") + + us_queryset = userstories_utils.attach_total_points(us_queryset) + us_queryset = userstories_utils.attach_role_points(us_queryset) + us_queryset = userstories_utils.attach_epics(us_queryset) + + queryset = queryset.prefetch_related(Prefetch("user_stories", queryset=us_queryset)) + + queryset = attach_total_points(queryset) + queryset = attach_closed_points(queryset) + + return queryset diff --git a/taiga/projects/milestones/validators.py b/taiga/projects/milestones/validators.py new file mode 100644 index 000000000..bb47a417b --- /dev/null +++ b/taiga/projects/milestones/validators.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base.exceptions import ValidationError +from taiga.base.api import serializers +from taiga.base.api import validators +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_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") + raise ValidationError(msg) + return attrs + + +class MilestoneValidator(WatchersValidator, DuplicatedNameInProjectValidator, validators.ModelValidator): + 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 diff --git a/taiga/projects/mixins/__init__.py b/taiga/projects/mixins/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/mixins/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/mixins/blocked.py b/taiga/projects/mixins/blocked.py new file mode 100644 index 000000000..3d1918f6f --- /dev/null +++ b/taiga/projects/mixins/blocked.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +########## +# MODELS # +########## + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.dispatch import receiver + + +class BlockedMixin(models.Model): + is_blocked = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is blocked")) + blocked_note = models.TextField(default="", null=False, blank=True, + verbose_name=_("blocked note")) + class Meta: + abstract = True + + +@receiver(models.signals.pre_save, dispatch_uid='blocked_pre_save') +def blocked_pre_save(sender, instance, **kwargs): + if isinstance(instance, BlockedMixin) and not instance.is_blocked: + instance.blocked_note = "" diff --git a/taiga/projects/mixins/by_ref.py b/taiga/projects/mixins/by_ref.py new file mode 100644 index 000000000..8c3c5d723 --- /dev/null +++ b/taiga/projects/mixins/by_ref.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base import response +from taiga.base.decorators import list_route + + +class ByRefMixin: + """ + Get an instance by ref. + """ + @list_route(methods=["GET"]) + def by_ref(self, request): + if "ref" not in request.QUERY_PARAMS: + return response.BadRequest(_("ref param is needed")) + + if "project__slug" not in request.QUERY_PARAMS and "project" not in request.QUERY_PARAMS: + return response.BadRequest(_("project or project__slug param is needed")) + + retrieve_kwargs = { + "ref": request.QUERY_PARAMS.get("ref", None) + } + project_id = request.QUERY_PARAMS.get("project", None) + if project_id is not None: + retrieve_kwargs["project_id"] = project_id + + project_slug = request.QUERY_PARAMS.get("project__slug", None) + if project_slug is not None: + retrieve_kwargs["project__slug"] = project_slug + + return self.retrieve(request, **retrieve_kwargs) diff --git a/taiga/projects/mixins/on_destroy.py b/taiga/projects/mixins/on_destroy.py new file mode 100644 index 000000000..dd900a0e7 --- /dev/null +++ b/taiga/projects/mixins/on_destroy.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import transaction as tx +from django.utils.translation import gettext as _ + +from taiga.base import exceptions as exc +from taiga.base.api.utils import get_object_or_404 +from taiga.base import response +from taiga.projects.services.bulk_update_order import update_order_and_swimlane + + +############################################# +# ViewSets +############################################# + +class MoveOnDestroyMixin: + move_on_destroy_post_destroy_signal = None + + def move_on_destroy_reorder_after_moved(self, moved_to_obj, moved_objs_queryset): + pass + + @tx.atomic + def destroy(self, request, *args, **kwargs): + # moveTo is needed + move_to = self.request.QUERY_PARAMS.get('moveTo', None) + if move_to is None: + raise exc.BadRequest(_("Query param 'moveTo' is required")) + + # check permisions over moveTo object + move_item = get_object_or_404(self.model, id=move_to) + self.check_permissions(request, 'update', move_item) + + obj = self.get_object_or_none() + + qs = self.move_on_destroy_related_class.objects.filter(**{self.move_on_destroy_related_field: obj}) + # Reorder after moved + self.move_on_destroy_reorder_after_moved(move_item, qs) + # move related objects to the new one. + # (we need to do this befero to prevent some deletes-on-cascade behaivors) + qs.update(**{self.move_on_destroy_related_field: move_item}) + + # change default project value if is needed + if getattr(obj.project, self.move_on_destroy_project_default_field) == obj: + setattr(obj.project, self.move_on_destroy_project_default_field, move_item) + obj.project.save() + changed_default_value = True + + # destroy object + res = super().destroy(request, *args, **kwargs) + + # if the object is not deleted + if not isinstance(res, response.NoContent): + # Restart status (roolback) if we can't delete the object + qs.update(**{self.move_on_destroy_related_field: obj}) + + # Restart default value + if changed_default_value: + setattr(obj.project, self.move_on_destroy_project_default_field, obj) + obj.project.save() + else: + if self.move_on_destroy_post_destroy_signal: + # throw post delete signal + self.move_on_destroy_post_destroy_signal.send(obj.__class__, deleted=obj, moved=move_item) + + return res + + +class MoveOnDestroySwimlaneMixin: + @tx.atomic + def destroy(self, request, *args, **kwargs): + obj = self.get_object_or_none() + self.check_permissions(request, 'destroy', obj) + + move_to = self.request.QUERY_PARAMS.get('moveTo', None) + if move_to is None: + total_elements = obj.project.swimlanes.count() + # you cannot set swimlane=None if there are more swimlanes available + if total_elements > 1: + raise exc.BadRequest(_("Cannot set swimlane to None if there are available swimlanes")) + + # but if it was the last swimlane, + # it can be deleted and all uss have now swimlane=None + obj.user_stories.update(swimlane_id=None) + else: + move_item = get_object_or_404(self.model, id=move_to) + + # check permisions over moveTo object + self.check_permissions(request, 'destroy', move_item) + + update_order_and_swimlane(obj, move_item) + + return super().destroy(request, *args, **kwargs) diff --git a/taiga/projects/mixins/ordering.py b/taiga/projects/mixins/ordering.py new file mode 100644 index 000000000..73d6fce77 --- /dev/null +++ b/taiga/projects/mixins/ordering.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base import response +from taiga.base import exceptions as exc +from taiga.base.api.utils import get_object_or_404 +from taiga.base.decorators import list_route +from taiga.projects.models import Project + + +############################################# +# ViewSets +############################################# + +class BulkUpdateOrderMixin: + """ + This mixin need three fields in the child class: + + - bulk_update_param: the name of the field of the data received from + the client that contains the pairs (id, order) to sort the objects. + - bulk_update_perm: the codename of the permission needed to sort. + - bulk_update_order: method with bulk update order logic + """ + + @list_route(methods=["POST"]) + def bulk_update_order(self, request, **kwargs): + bulk_data = request.DATA.get(self.bulk_update_param, None) + + if bulk_data is None: + raise exc.BadRequest(_("'{param}' parameter is mandatory".format(param=self.bulk_update_param))) + + project_id = request.DATA.get('project', None) + if project_id is None: + raise exc.BadRequest(_("'project' parameter is mandatory")) + + project = get_object_or_404(Project, id=project_id) + + self.check_permissions(request, 'bulk_update_order', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + self.__class__.bulk_update_order_action(project, request.user, bulk_data) + return response.NoContent(data=None) diff --git a/taiga/projects/mixins/promote.py b/taiga/projects/mixins/promote.py new file mode 100644 index 000000000..f52c9b5de --- /dev/null +++ b/taiga/projects/mixins/promote.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from taiga.base import response +from taiga.base.api.utils import get_object_or_404 +from taiga.base.decorators import detail_route +from taiga.projects.models import Project +from taiga.projects.tasks.models import Task +from taiga.projects.services.promote import promote_to_us + +from . import validators + + +class PromoteToUserStoryMixin: + """ + Promote an instance to User Story. + """ + + @detail_route(methods=["POST"]) + def promote_to_user_story(self, request, pk=None): + validator = validators.PromoteToUserStoryValidator(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"]) + self.check_permissions(request, 'promote_to_us', project) + + obj = self.get_object() + ret = promote_to_us(obj) + self.persist_history_snapshot(obj=obj) + + # delete source task if required + if isinstance(obj, Task): + obj.delete() + + return response.Ok(ret) diff --git a/taiga/projects/mixins/serializers.py b/taiga/projects/mixins/serializers.py new file mode 100644 index 000000000..6960d3795 --- /dev/null +++ b/taiga/projects/mixins/serializers.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.projects import services +from taiga.users.serializers import UserBasicInfoSerializer + + +class CachedUsersSerializerMixin(serializers.LightSerializer): + def to_value(self, instance): + self._serialized_users = {} + return super().to_value(instance) + + def get_user_extra_info(self, user): + if user is None: + return None + + serialized_user = self._serialized_users.get(user.id, None) + if serialized_user is None: + serialized_user = UserBasicInfoSerializer(user).data + self._serialized_users[user.id] = serialized_user + + return serialized_user + + +class OwnerExtraInfoSerializerMixin(CachedUsersSerializerMixin): + owner = Field(attr="owner_id") + owner_extra_info = MethodField() + + def get_owner_extra_info(self, obj): + return self.get_user_extra_info(obj.owner) + + +class AssignedToExtraInfoSerializerMixin(CachedUsersSerializerMixin): + assigned_to = Field(attr="assigned_to_id") + assigned_to_extra_info = MethodField() + + def get_assigned_to_extra_info(self, obj): + return self.get_user_extra_info(obj.assigned_to) + + +class StatusExtraInfoSerializerMixin(serializers.LightSerializer): + status = Field(attr="status_id") + status_extra_info = MethodField() + + def to_value(self, instance): + self._serialized_status = {} + return super().to_value(instance) + + def get_status_extra_info(self, obj): + if obj.status_id is None: + return None + + serialized_status = self._serialized_status.get(obj.status_id, None) + if serialized_status is None: + serialized_status = { + "name": _(obj.status.name), + "color": obj.status.color, + "is_closed": obj.status.is_closed + } + self._serialized_status[obj.status_id] = serialized_status + + return serialized_status + + +class ProjectExtraInfoSerializerMixin(serializers.LightSerializer): + project = Field(attr="project_id") + project_extra_info = MethodField() + + def to_value(self, instance): + self._serialized_project = {} + return super().to_value(instance) + + def get_project_extra_info(self, obj): + if obj.project_id is None: + return None + + serialized_project = self._serialized_project.get(obj.project_id, None) + if serialized_project is None: + serialized_project = { + "name": obj.project.name, + "slug": obj.project.slug, + "logo_small_url": services.get_logo_small_thumbnail_url(obj.project), + "id": obj.project_id + } + self._serialized_project[obj.project_id] = serialized_project + + return serialized_project diff --git a/taiga/projects/mixins/validators.py b/taiga/projects/mixins/validators.py new file mode 100644 index 000000000..c9850322b --- /dev/null +++ b/taiga/projects/mixins/validators.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base.api import validators, serializers +from taiga.base.exceptions import ValidationError +from taiga.projects.models import Membership +from taiga.projects.validators import ProjectExistsValidator + + +class AssignedToValidator: + def validate_assigned_to(self, attrs, source): + assigned_to = attrs[source] + project = (attrs.get("project", None) or + getattr(self.object, "project", None)) + + if assigned_to and project: + filters = { + "project_id": project.id, + "user_id": assigned_to.id + } + + if not Membership.objects.filter(**filters).exists(): + raise ValidationError(_("The user must be a project member.")) + + return attrs + + +class PromoteToUserStoryValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() diff --git a/taiga/projects/models.py b/taiga/projects/models.py new file mode 100644 index 000000000..b536e3e8b --- /dev/null +++ b/taiga/projects/models.py @@ -0,0 +1,1436 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.apps import apps +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from django.utils.functional import cached_property + +from django_pglocks import advisory_lock + +from taiga.base.db.models.fields import JSONField + +from taiga.base.utils.time import timestamp_ms +from taiga.projects.custom_attributes.models import EpicCustomAttribute +from taiga.projects.custom_attributes.models import UserStoryCustomAttribute +from taiga.projects.custom_attributes.models import TaskCustomAttribute +from taiga.projects.custom_attributes.models import IssueCustomAttribute +from taiga.projects.tagging.models import TaggedMixin +from taiga.projects.tagging.models import TagsColorsMixin +from taiga.base.utils.files import get_file_path +from taiga.base.utils.slug import slugify_uniquely +from taiga.base.utils.slug import slugify_uniquely_for_queryset + +from taiga.permissions.choices import ANON_PERMISSIONS, MEMBERS_PERMISSIONS + +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.services import ( + set_notify_policy_level, + set_notify_policy_level_to_ignore, + create_notify_policy_if_not_exists) + +from taiga.timeline.service import build_project_namespace + +from . import choices + +from dateutil.relativedelta import relativedelta + + +def get_project_logo_file_path(instance, filename): + return get_file_path(instance, filename, "project") + + +class Membership(models.Model): + # This model stores all project memberships. Also + # stores invitations to memberships that does not have + # assigned user. + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + default=None, + related_name="memberships", + on_delete=models.CASCADE, + ) + + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="memberships", + on_delete=models.CASCADE, + ) + role = models.ForeignKey( + "users.Role", + null=False, + blank=False, + related_name="memberships", + on_delete=models.CASCADE, + ) + + is_admin = models.BooleanField(default=False, null=False, blank=False) + + # Invitation metadata + email = models.EmailField(max_length=255, default=None, null=True, blank=True, + verbose_name=_("email")) + created_at = models.DateTimeField(default=timezone.now, + verbose_name=_("create at")) + token = models.CharField(max_length=60, blank=True, null=True, default=None, + verbose_name=_("token")) + + invited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="ihaveinvited+", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + + invitation_extra_text = models.TextField(null=True, blank=True, + verbose_name=_("invitation extra text")) + + user_order = models.BigIntegerField(default=timestamp_ms, null=False, blank=False, + verbose_name=_("user order")) + + class Meta: + verbose_name = "membership" + verbose_name_plural = "memberships" + unique_together = ("user", "project",) + ordering = ["project", "user__full_name", "user__username", "user__email", "email"] + + def get_related_people(self): + related_people = get_user_model().objects.filter(id=self.user.id) + return related_people + + def clean(self): + # TODO: Review and do it more robust + memberships = Membership.objects.filter(user=self.user, project=self.project) + if self.user and memberships.count() > 0 and memberships[0].id != self.id: + raise ValidationError(_('The user is already member of the project')) + + +class ProjectDefaults(models.Model): + default_epic_status = models.OneToOneField("projects.EpicStatus", + on_delete=models.SET_NULL, related_name="+", + null=True, blank=True, + verbose_name=_("default epic status")) + default_us_status = models.OneToOneField("projects.UserStoryStatus", + on_delete=models.SET_NULL, related_name="+", + null=True, blank=True, + verbose_name=_("default US status")) + default_points = models.OneToOneField("projects.Points", on_delete=models.SET_NULL, + related_name="+", null=True, blank=True, + verbose_name=_("default points")) + default_task_status = models.OneToOneField("projects.TaskStatus", + on_delete=models.SET_NULL, related_name="+", + null=True, blank=True, + verbose_name=_("default task status")) + default_priority = models.OneToOneField("projects.Priority", on_delete=models.SET_NULL, + related_name="+", null=True, blank=True, + verbose_name=_("default priority")) + default_severity = models.OneToOneField("projects.Severity", on_delete=models.SET_NULL, + related_name="+", null=True, blank=True, + verbose_name=_("default severity")) + default_issue_status = models.OneToOneField("projects.IssueStatus", + on_delete=models.SET_NULL, related_name="+", + null=True, blank=True, + verbose_name=_("default issue status")) + default_issue_type = models.OneToOneField("projects.IssueType", + on_delete=models.SET_NULL, related_name="+", + null=True, blank=True, + verbose_name=_("default issue type")) + default_swimlane = models.OneToOneField("projects.Swimlane", + on_delete=models.SET_NULL, related_name="+", + null=True, blank=True, + verbose_name=_("default swimlane")) + + class Meta: + abstract = True + + +class Project(ProjectDefaults, TaggedMixin, TagsColorsMixin, models.Model): + name = models.CharField(max_length=250, null=False, blank=False, + verbose_name=_("name")) + slug = models.SlugField(max_length=250, unique=True, null=False, blank=True, + verbose_name=_("slug")) + description = models.TextField(null=False, blank=False, + verbose_name=_("description")) + + logo = models.FileField(upload_to=get_project_logo_file_path, + max_length=500, null=True, blank=True, + verbose_name=_("logo")) + + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + related_name="owned_projects", + verbose_name=_("owner"), + on_delete=models.SET_NULL, + ) + + members = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="projects", + through="Membership", verbose_name=_("members"), + through_fields=("project", "user")) + total_milestones = models.IntegerField(null=True, blank=True, + verbose_name=_("total of milestones")) + total_story_points = models.FloatField(null=True, blank=True, verbose_name=_("total story points")) + is_contact_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active contact")) + is_epics_activated = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("active epics panel")) + is_backlog_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active backlog panel")) + is_kanban_activated = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("active kanban panel")) + is_wiki_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active wiki panel")) + is_issues_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active issues panel")) + videoconferences = models.CharField(max_length=250, null=True, blank=True, + choices=choices.VIDEOCONFERENCES_CHOICES, + verbose_name=_("videoconference system")) + videoconferences_extra_data = models.CharField(max_length=250, null=True, blank=True, + verbose_name=_("videoconference extra data")) + + creation_template = models.ForeignKey( + "projects.ProjectTemplate", + related_name="projects", + null=True, + on_delete=models.SET_NULL, + blank=True, + default=None, + verbose_name=_("creation template")) + + is_private = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("is private")) + anon_permissions = ArrayField(models.TextField(null=False, blank=False, choices=ANON_PERMISSIONS), + null=True, blank=True, default=list, verbose_name=_("anonymous permissions")) + public_permissions = ArrayField(models.TextField(null=False, blank=False, choices=MEMBERS_PERMISSIONS), + null=True, blank=True, default=list, verbose_name=_("user permissions")) + + is_featured = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is featured")) + + is_looking_for_people = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is looking for people")) + looking_for_people_note = models.TextField(default="", null=False, blank=True, + verbose_name=_("looking for people note")) + + epics_csv_uuid = models.CharField(max_length=32, editable=False, null=True, + blank=True, default=None, db_index=True) + userstories_csv_uuid = models.CharField(max_length=32, editable=False, + null=True, blank=True, + default=None, db_index=True) + tasks_csv_uuid = models.CharField(max_length=32, editable=False, null=True, + blank=True, default=None, db_index=True) + issues_csv_uuid = models.CharField(max_length=32, editable=False, + null=True, blank=True, default=None, + db_index=True) + + transfer_token = models.CharField(max_length=255, null=True, blank=True, default=None, + verbose_name=_("project transfer token")) + + blocked_code = models.CharField(null=True, blank=True, max_length=255, + choices=choices.BLOCKING_CODES + settings.EXTRA_BLOCKING_CODES, + default=None, verbose_name=_("blocked code")) + # Totals: + totals_updated_datetime = models.DateTimeField(null=False, blank=False, auto_now_add=True, + verbose_name=_("updated date time"), db_index=True) + + total_fans = models.PositiveIntegerField(null=False, blank=False, default=0, + verbose_name=_("count"), db_index=True) + + total_fans_last_week = models.PositiveIntegerField(null=False, blank=False, default=0, + verbose_name=_("fans last week"), db_index=True) + + total_fans_last_month = models.PositiveIntegerField(null=False, blank=False, default=0, + verbose_name=_("fans last month"), db_index=True) + + total_fans_last_year = models.PositiveIntegerField(null=False, blank=False, default=0, + verbose_name=_("fans last year"), db_index=True) + + total_activity = models.PositiveIntegerField(null=False, blank=False, default=0, + verbose_name=_("count"), + db_index=True) + + total_activity_last_week = models.PositiveIntegerField(null=False, blank=False, default=0, + verbose_name=_("activity last week"), + db_index=True) + + total_activity_last_month = models.PositiveIntegerField(null=False, blank=False, default=0, + verbose_name=_("activity last month"), + db_index=True) + + total_activity_last_year = models.PositiveIntegerField(null=False, blank=False, default=0, + verbose_name=_("activity last year"), + db_index=True) + + _importing = None + + class Meta: + verbose_name = "project" + verbose_name_plural = "projects" + ordering = ["name", "id"] + index_together = [ + ["name", "id"], + ] + + def __str__(self): + return self.name + + def __repr__(self): + return "".format(self.id) + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + if not self.is_backlog_activated: + self.total_milestones = None + self.total_story_points = None + + if not self.videoconferences: + self.videoconferences_extra_data = None + + if not self.is_looking_for_people: + self.looking_for_people_note = "" + + if self.anon_permissions is None: + self.anon_permissions = [] + + if self.public_permissions is None: + self.public_permissions = [] + + if not self.slug: + with advisory_lock("project-creation"): + base_slug = self.name + if settings.DEFAULT_PROJECT_SLUG_PREFIX: + base_slug = "{}-{}".format(self.owner.username, self.name) + self.slug = slugify_uniquely(base_slug, self.__class__) + super().save(*args, **kwargs) + else: + super().save(*args, **kwargs) + + def refresh_totals(self, save=True): + now = timezone.now() + self.totals_updated_datetime = now + + Like = apps.get_model("likes", "Like") + content_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(Project) + qs = Like.objects.filter(content_type=content_type, object_id=self.id) + + self.total_fans = qs.count() + + qs_week = qs.filter(created_date__gte=now - relativedelta(weeks=1)) + self.total_fans_last_week = qs_week.count() + + qs_month = qs.filter(created_date__gte=now - relativedelta(months=1)) + self.total_fans_last_month = qs_month.count() + + qs_year = qs.filter(created_date__gte=now - relativedelta(years=1)) + self.total_fans_last_year = qs_year.count() + + tl_model = apps.get_model("timeline", "Timeline") + namespace = build_project_namespace(self) + + qs = tl_model.objects.filter(namespace=namespace) + self.total_activity = qs.count() + + qs_week = qs.filter(created__gte=now - relativedelta(weeks=1)) + self.total_activity_last_week = qs_week.count() + + qs_month = qs.filter(created__gte=now - relativedelta(months=1)) + self.total_activity_last_month = qs_month.count() + + qs_year = qs.filter(created__gte=now - relativedelta(years=1)) + self.total_activity_last_year = qs_year.count() + + if save: + self.save(update_fields=[ + 'totals_updated_datetime', + 'total_fans', + 'total_fans_last_week', + 'total_fans_last_month', + 'total_fans_last_year', + 'total_activity', + 'total_activity_last_week', + 'total_activity_last_month', + 'total_activity_last_year', + ]) + + @cached_property + def cached_user_stories(self): + return list(self.user_stories.all()) + + @cached_property + def cached_notify_policies(self): + return {np.user.id: np for np in self.notify_policies.select_related("user", "project")} + + def cached_notify_policy_for_user(self, user): + """ + Get notification level for specified project and user. + """ + policy = self.cached_notify_policies.get(user.id, None) + if policy is None: + model_cls = apps.get_model("notifications", "NotifyPolicy") + policy = model_cls.objects.create( + project=self, + user=user, + notify_level=NotifyLevel.involved) + + del self.cached_notify_policies + + return policy + + @cached_property + def cached_memberships(self): + return {m.user.id: m for m in self.memberships.exclude(user__isnull=True) + .select_related("user", "project", "role")} + + def cached_memberships_for_user(self, user): + return self.cached_memberships.get(user.id, None) + + def get_roles(self): + return self.roles.all() + + def get_users(self, with_admin_privileges=None): + user_model = get_user_model() + members = self.memberships.all() + if with_admin_privileges is not None: + members = members.filter(Q(is_admin=True) | Q(user__id=self.owner.id)) + members = members.values_list("user", flat=True) + return user_model.objects.filter(id__in=list(members)) + + def update_role_points(self, user_stories=None): + RolePoints = apps.get_model("userstories", "RolePoints") + Role = apps.get_model("users", "Role") + + # Get all available roles on this project + roles = self.get_roles().filter(computable=True) + if roles.count() == 0: + return + + # Iter over all project user stories and create + # role point instance for new created roles. + if user_stories is None: + user_stories = self.user_stories.all() + + # Get point instance that represent a null/undefined + # The current model allows duplicate values. Because + # of it, we should get all poins with None as value + # and use the first one. + # In case of that not exists, creates one for avoid + # unexpected errors. + none_points = list(self.points.filter(value=None)) + if none_points: + null_points_value = none_points[0] + else: + name = slugify_uniquely_for_queryset("?", self.points.all(), slugfield="name") + null_points_value = Points.objects.create(name=name, value=None, project=self) + + for us in user_stories: + usroles = Role.objects.filter(role_points__in=us.role_points.all()).distinct() + new_roles = roles.exclude(id__in=usroles) + new_rolepoints = [RolePoints(role=role, user_story=us, points=null_points_value) + for role in new_roles] + RolePoints.objects.bulk_create(new_rolepoints) + + # Now remove rolepoints associated with not existing roles. + rp_query = RolePoints.objects.filter(user_story__in=self.user_stories.all()) + rp_query = rp_query.exclude(role__id__in=roles.values_list("id", flat=True)) + rp_query.delete() + + @property + def project(self): + return self + + def _get_q_watchers(self): + return Q(notify_policies__project_id=self.id) & ~Q(notify_policies__notify_level=NotifyLevel.none) + + def get_watchers(self): + return get_user_model().objects.filter(self._get_q_watchers()) + + def get_related_people(self): + related_people_q = Q() + + ## - Owner + if self.owner_id: + related_people_q.add(Q(id=self.owner_id), Q.OR) + + ## - Watchers + related_people_q.add(self._get_q_watchers(), Q.OR) + + ## - Apply filters + related_people = get_user_model().objects.filter(related_people_q) + + ## - Exclude inactive and system users and remove duplicate + related_people = related_people.exclude(is_active=False) + related_people = related_people.exclude(is_system=True) + related_people = related_people.distinct() + return related_people + + def add_watcher(self, user, notify_level=NotifyLevel.all): + notify_policy = create_notify_policy_if_not_exists(self, user) + set_notify_policy_level(notify_policy, notify_level) + + def remove_watcher(self, user): + notify_policy = self.cached_notify_policy_for_user(user) + set_notify_policy_level_to_ignore(notify_policy) + + def delete_related_content(self): + # NOTE: Remember to update code in taiga.projects.admin.ProjectAdmin.delete_queryset + from taiga.events.apps import (connect_events_signals, + disconnect_events_signals) + from taiga.projects.epics.apps import (connect_all_epics_signals, + disconnect_all_epics_signals) + from taiga.projects.tasks.apps import (connect_all_tasks_signals, + disconnect_all_tasks_signals) + from taiga.projects.userstories.apps import (connect_all_userstories_signals, + disconnect_all_userstories_signals) + from taiga.projects.issues.apps import (connect_all_issues_signals, + disconnect_all_issues_signals) + from taiga.projects.apps import (connect_memberships_signals, + disconnect_memberships_signals) + + disconnect_events_signals() + disconnect_all_epics_signals() + disconnect_all_issues_signals() + disconnect_all_tasks_signals() + disconnect_all_userstories_signals() + disconnect_memberships_signals() + + try: + self.epics.all().delete() + self.tasks.all().delete() + self.user_stories.all().delete() + self.issues.all().delete() + self.memberships.all().delete() + self.roles.all().delete() + finally: + connect_events_signals() + connect_all_issues_signals() + connect_all_tasks_signals() + connect_all_userstories_signals() + connect_all_epics_signals() + connect_memberships_signals() + + +class ProjectModulesConfig(models.Model): + project = models.OneToOneField( + "Project", + null=False, + blank=False, + related_name="modules_config", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + config = JSONField(null=True, blank=True, verbose_name=_("modules config")) + + class Meta: + verbose_name = "project modules config" + verbose_name_plural = "project modules configs" + ordering = ["project"] + + +# Epic common Models +class EpicStatus(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + slug = models.SlugField(max_length=255, null=False, blank=True, + verbose_name=_("slug")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + is_closed = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is closed")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="epic_statuses", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "epic status" + verbose_name_plural = "epic statuses" + ordering = ["project", "order", "name"] + unique_together = (("project", "name"), ("project", "slug")) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + qs = self.project.epic_statuses + if self.id: + qs = qs.exclude(id=self.id) + + self.slug = slugify_uniquely_for_queryset(self.name, qs) + return super().save(*args, **kwargs) + + +# User Stories common Models +class UserStoryStatus(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + slug = models.SlugField(max_length=255, null=False, blank=True, + verbose_name=_("slug")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + is_closed = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is closed")) + is_archived = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is archived")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + wip_limit = models.IntegerField(null=True, blank=True, default=None, + verbose_name=_("work in progress limit")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="us_statuses", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + _importing = None + + class Meta: + verbose_name = "user story status" + verbose_name_plural = "user story statuses" + ordering = ["project", "order", "name"] + unique_together = (("project", "name"), ("project", "slug")) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + qs = self.project.us_statuses + if self.id: + qs = qs.exclude(id=self.id) + + self.slug = slugify_uniquely_for_queryset(self.name, qs) + return super().save(*args, **kwargs) + + +class Points(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + value = models.FloatField(default=None, null=True, blank=True, + verbose_name=_("value")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="points", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "points" + verbose_name_plural = "points" + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + +class UserStoryDueDate(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + by_default = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("by default")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + days_to_due = models.IntegerField(null=True, blank=True, default=None, + verbose_name=_("days to due")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="us_duedates", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "user story due date" + verbose_name_plural = "user story due dates" + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + +# Tasks common models +class TaskStatus(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + slug = models.SlugField(max_length=255, null=False, blank=True, + verbose_name=_("slug")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + is_closed = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is closed")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="task_statuses", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "task status" + verbose_name_plural = "task statuses" + ordering = ["project", "order", "name"] + unique_together = (("project", "name"), ("project", "slug")) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + qs = self.project.task_statuses + if self.id: + qs = qs.exclude(id=self.id) + + self.slug = slugify_uniquely_for_queryset(self.name, qs) + return super().save(*args, **kwargs) + + +class TaskDueDate(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + by_default = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("by default")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + days_to_due = models.IntegerField(null=True, blank=True, default=None, + verbose_name=_("days to due")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="task_duedates", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "task due date" + verbose_name_plural = "task due dates" + ordering = ["project", "order", "name"] + unique_together = (("project", "name")) + + def __str__(self): + return self.name + + +# Issue common Models + +class Priority(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="priorities", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "priority" + verbose_name_plural = "priorities" + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + +class Severity(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="severities", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "severity" + verbose_name_plural = "severities" + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + +class IssueStatus(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + slug = models.SlugField(max_length=255, null=False, blank=True, + verbose_name=_("slug")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + is_closed = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is closed")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="issue_statuses", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "issue status" + verbose_name_plural = "issue statuses" + ordering = ["project", "order", "name"] + unique_together = (("project", "name"), ("project", "slug")) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + qs = self.project.issue_statuses + if self.id: + qs = qs.exclude(id=self.id) + + self.slug = slugify_uniquely_for_queryset(self.name, qs) + return super().save(*args, **kwargs) + + +class IssueType(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="issue_types", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "issue type" + verbose_name_plural = "issue types" + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + +class IssueDueDate(models.Model): + name = models.CharField(max_length=255, null=False, blank=False, + verbose_name=_("name")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + by_default = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("by default")) + color = models.CharField(max_length=20, null=False, blank=False, default="#999999", + verbose_name=_("color")) + days_to_due = models.IntegerField(null=True, blank=True, default=None, + verbose_name=_("days to due")) + project = models.ForeignKey( + "Project", + null=False, + blank=False, + related_name="issue_duedates", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "issue due date" + verbose_name_plural = "issue due dates" + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + +class Swimlane(models.Model): + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="swimlanes", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + name = models.TextField(null=False, blank=False, + verbose_name=_("name")) + order = models.BigIntegerField(null=False, blank=False, default=timestamp_ms, + verbose_name=_("order")) + + _importing = None + + class Meta: + verbose_name = "swimlane" + verbose_name_plural = "swimlanes" + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + +class SwimlaneUserStoryStatus(models.Model): + wip_limit = models.IntegerField(null=True, blank=True, default=None, + verbose_name=_("work in progress limit")) + status = models.ForeignKey( + "projects.UserStoryStatus", + null=False, + blank=False, + on_delete=models.CASCADE, + related_name="swimlane_statuses", + verbose_name=_("user story status"), + ) + swimlane = models.ForeignKey( + "projects.Swimlane", + null=False, + blank=False, + on_delete=models.CASCADE, + related_name="statuses", + verbose_name=_("status"), + ) + + exluded_events = ["create", "delete"] + + _importing = None + + class Meta: + verbose_name = "swimlane user story status" + verbose_name_plural = "swimlane user story statuses" + ordering = ["swimlane", "status", "id"] + unique_together = (("swimlane", "status")) + + def __str__(self): + return f"{self.swimlane.name} - {self.status.name}" + + def __repr__(self): + return "" + + @property + def project(self): + return self.status.project + + @property + def project_id(self): + return self.status.project_id + + +class ProjectTemplate(TaggedMixin, TagsColorsMixin, models.Model): + name = models.CharField(max_length=250, null=False, blank=False, + verbose_name=_("name")) + slug = models.SlugField(max_length=250, null=False, blank=True, + verbose_name=_("slug"), unique=True) + description = models.TextField(null=False, blank=False, + verbose_name=_("description")) + order = models.BigIntegerField(default=timestamp_ms, null=False, blank=False, + verbose_name=_("user order")) + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + default_owner_role = models.CharField(max_length=50, null=False, + blank=False, + verbose_name=_("default owner's role")) + is_contact_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active contact")) + is_epics_activated = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("active epics panel")) + is_backlog_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active backlog panel")) + is_kanban_activated = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("active kanban panel")) + is_wiki_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active wiki panel")) + is_issues_activated = models.BooleanField(default=True, null=False, blank=True, + verbose_name=_("active issues panel")) + is_looking_for_people = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is looking for people")) + looking_for_people_note = models.TextField(default="", null=False, blank=True, + verbose_name=_("looking for people note")) + videoconferences = models.CharField(max_length=250, null=True, blank=True, + choices=choices.VIDEOCONFERENCES_CHOICES, + verbose_name=_("videoconference system")) + videoconferences_extra_data = models.CharField(max_length=250, null=True, blank=True, + verbose_name=_("videoconference extra data")) + + default_options = JSONField(null=True, blank=True, verbose_name=_("default options")) + epic_statuses = JSONField(null=True, blank=True, verbose_name=_("epic statuses")) + us_statuses = JSONField(null=True, blank=True, verbose_name=_("us statuses")) + us_duedates = JSONField(null=True, blank=True, verbose_name=_("us duedates")) + points = JSONField(null=True, blank=True, verbose_name=_("points")) + task_statuses = JSONField(null=True, blank=True, verbose_name=_("task statuses")) + task_duedates = JSONField(null=True, blank=True, verbose_name=_("task duedates")) + issue_statuses = JSONField(null=True, blank=True, verbose_name=_("issue statuses")) + issue_types = JSONField(null=True, blank=True, verbose_name=_("issue types")) + issue_duedates = JSONField(null=True, blank=True, verbose_name=_("issue duedates")) + priorities = JSONField(null=True, blank=True, verbose_name=_("priorities")) + severities = JSONField(null=True, blank=True, verbose_name=_("severities")) + roles = JSONField(null=True, blank=True, verbose_name=_("roles")) + epic_custom_attributes = JSONField(null=True, blank=True, verbose_name=_("epic custom attributes")) + us_custom_attributes = JSONField(null=True, blank=True, verbose_name=_("us custom attributes")) + task_custom_attributes = JSONField(null=True, blank=True, verbose_name=_("task custom attributes")) + issue_custom_attributes = JSONField(null=True, blank=True, verbose_name=_("issue custom attributes")) + + _importing = None + + class Meta: + verbose_name = "project template" + verbose_name_plural = "project templates" + ordering = ["order", "name"] + + def __str__(self): + return self.name + + def __repr__(self): + return "".format(self.slug) + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + if not self.slug: + self.slug = slugify_uniquely(self.name, self.__class__) + super().save(*args, **kwargs) + + def load_data_from_project(self, project): + self.is_contact_activated = project.is_contact_activated + self.is_epics_activated = project.is_epics_activated + self.is_backlog_activated = project.is_backlog_activated + self.is_kanban_activated = project.is_kanban_activated + self.is_wiki_activated = project.is_wiki_activated + self.is_issues_activated = project.is_issues_activated + self.videoconferences = project.videoconferences + self.videoconferences_extra_data = project.videoconferences_extra_data + + self.default_options = { + "points": getattr(project.default_points, "name", None), + "epic_status": getattr(project.default_epic_status, "name", None), + "us_status": getattr(project.default_us_status, "name", None), + "task_status": getattr(project.default_task_status, "name", None), + "issue_status": getattr(project.default_issue_status, "name", None), + "issue_type": getattr(project.default_issue_type, "name", None), + "priority": getattr(project.default_priority, "name", None), + "severity": getattr(project.default_severity, "name", None) + } + + self.epic_statuses = [] + for epic_status in project.epic_statuses.all(): + self.epic_statuses.append({ + "name": epic_status.name, + "slug": epic_status.slug, + "is_closed": epic_status.is_closed, + "color": epic_status.color, + "order": epic_status.order, + }) + + self.us_statuses = [] + for us_status in project.us_statuses.all(): + self.us_statuses.append({ + "name": us_status.name, + "slug": us_status.slug, + "is_closed": us_status.is_closed, + "is_archived": us_status.is_archived, + "color": us_status.color, + "wip_limit": us_status.wip_limit, + "order": us_status.order, + }) + + self.us_duedates = [] + for us_duedate in project.us_duedates.all(): + self.us_duedates.append({ + "name": us_duedate.name, + "by_default": us_duedate.by_default, + "color": us_duedate.color, + "days_to_due": us_duedate.days_to_due, + "order": us_duedate.order, + }) + + self.points = [] + for us_point in project.points.all(): + self.points.append({ + "name": us_point.name, + "value": us_point.value, + "order": us_point.order, + }) + + self.task_statuses = [] + for task_status in project.task_statuses.all(): + self.task_statuses.append({ + "name": task_status.name, + "slug": task_status.slug, + "is_closed": task_status.is_closed, + "color": task_status.color, + "order": task_status.order, + }) + + self.task_duedates = [] + for task_duedate in project.task_duedates.all(): + self.task_duedates.append({ + "name": task_duedate.name, + "by_default": task_duedate.by_default, + "color": task_duedate.color, + "days_to_due": task_duedate.days_to_due, + "order": task_duedate.order, + }) + + self.issue_statuses = [] + for issue_status in project.issue_statuses.all(): + self.issue_statuses.append({ + "name": issue_status.name, + "slug": issue_status.slug, + "is_closed": issue_status.is_closed, + "color": issue_status.color, + "order": issue_status.order, + }) + + self.issue_types = [] + for issue_type in project.issue_types.all(): + self.issue_types.append({ + "name": issue_type.name, + "color": issue_type.color, + "order": issue_type.order, + }) + + self.issue_duedates = [] + for issue_duedate in project.issue_duedates.all(): + self.issue_duedates.append({ + "name": issue_duedate.name, + "by_default": issue_duedate.by_default, + "color": issue_duedate.color, + "days_to_due": issue_duedate.days_to_due, + "order": issue_duedate.order, + }) + + self.priorities = [] + for priority in project.priorities.all(): + self.priorities.append({ + "name": priority.name, + "color": priority.color, + "order": priority.order, + }) + + self.severities = [] + for severity in project.severities.all(): + self.severities.append({ + "name": severity.name, + "color": severity.color, + "order": severity.order, + }) + + self.roles = [] + for role in project.roles.all(): + self.roles.append({ + "name": role.name, + "slug": role.slug, + "permissions": role.permissions, + "order": role.order, + "computable": role.computable + }) + + self.epic_custom_attributes = [] + for ca in project.epiccustomattributes.all(): + self.epic_custom_attributes.append({ + "name": ca.name, + "description": ca.description, + "type": ca.type, + "order": ca.order + }) + + self.us_custom_attributes = [] + for ca in project.userstorycustomattributes.all(): + self.us_custom_attributes.append({ + "name": ca.name, + "description": ca.description, + "type": ca.type, + "order": ca.order + }) + + self.task_custom_attributes = [] + for ca in project.taskcustomattributes.all(): + self.task_custom_attributes.append({ + "name": ca.name, + "description": ca.description, + "type": ca.type, + "order": ca.order + }) + + self.issue_custom_attributes = [] + for ca in project.issuecustomattributes.all(): + self.issue_custom_attributes.append({ + "name": ca.name, + "description": ca.description, + "type": ca.type, + "order": ca.order + }) + + try: + owner_membership = Membership.objects.get(project=project, user=project.owner) + self.default_owner_role = owner_membership.role.slug + except Membership.DoesNotExist: + self.default_owner_role = self.roles[0].get("slug", None) + + self.tags = project.tags + self.tags_colors = project.tags_colors + self.is_looking_for_people = project.is_looking_for_people + self.looking_for_people_note = project.looking_for_people_note + + def apply_to_project(self, project): + Role = apps.get_model("users", "Role") + + if project.id is None: + raise Exception("Project need an id (must be a saved project)") + + project.creation_template = self + project.is_contact_activated = self.is_contact_activated + project.is_epics_activated = self.is_epics_activated + project.is_backlog_activated = self.is_backlog_activated + project.is_kanban_activated = self.is_kanban_activated + project.is_wiki_activated = self.is_wiki_activated + project.is_issues_activated = self.is_issues_activated + project.videoconferences = self.videoconferences + project.videoconferences_extra_data = self.videoconferences_extra_data + + for epic_status in self.epic_statuses: + EpicStatus.objects.create( + name=epic_status["name"], + slug=epic_status["slug"], + is_closed=epic_status["is_closed"], + color=epic_status["color"], + order=epic_status["order"], + project=project + ) + + for us_status in self.us_statuses: + UserStoryStatus.objects.create( + name=us_status["name"], + slug=us_status["slug"], + is_closed=us_status["is_closed"], + is_archived=us_status["is_archived"], + color=us_status["color"], + wip_limit=us_status["wip_limit"], + order=us_status["order"], + project=project + ) + + for us_duedate in self.us_duedates: + UserStoryDueDate.objects.create( + name=us_duedate["name"], + by_default=us_duedate["by_default"], + color=us_duedate["color"], + days_to_due=us_duedate["days_to_due"], + order=us_duedate["order"], + project=project + ) + + for point in self.points: + Points.objects.create( + name=point["name"], + value=point["value"], + order=point["order"], + project=project + ) + + for task_status in self.task_statuses: + TaskStatus.objects.create( + name=task_status["name"], + slug=task_status["slug"], + is_closed=task_status["is_closed"], + color=task_status["color"], + order=task_status["order"], + project=project + ) + + for task_duedate in self.task_duedates: + TaskDueDate.objects.create( + name=task_duedate["name"], + by_default=task_duedate["by_default"], + color=task_duedate["color"], + days_to_due=task_duedate["days_to_due"], + order=task_duedate["order"], + project=project + ) + + for issue_status in self.issue_statuses: + IssueStatus.objects.create( + name=issue_status["name"], + slug=issue_status["slug"], + is_closed=issue_status["is_closed"], + color=issue_status["color"], + order=issue_status["order"], + project=project + ) + + for issue_type in self.issue_types: + IssueType.objects.create( + name=issue_type["name"], + color=issue_type["color"], + order=issue_type["order"], + project=project + ) + + for issue_duedate in self.issue_duedates: + IssueDueDate.objects.create( + name=issue_duedate["name"], + by_default=issue_duedate["by_default"], + color=issue_duedate["color"], + days_to_due=issue_duedate["days_to_due"], + order=issue_duedate["order"], + project=project + ) + + for priority in self.priorities: + Priority.objects.create( + name=priority["name"], + color=priority["color"], + order=priority["order"], + project=project + ) + + for severity in self.severities: + Severity.objects.create( + name=severity["name"], + color=severity["color"], + order=severity["order"], + project=project + ) + + for role in self.roles: + Role.objects.create( + name=role["name"], + slug=role["slug"], + order=role["order"], + computable=role["computable"], + project=project, + permissions=role['permissions'] + ) + + if self.epic_statuses: + project.default_epic_status = EpicStatus.objects.get(name=self.default_options["epic_status"], + project=project) + if self.us_statuses: + project.default_us_status = UserStoryStatus.objects.get(name=self.default_options["us_status"], + project=project) + if self.points: + project.default_points = Points.objects.get(name=self.default_options["points"], + project=project) + + if self.task_statuses: + project.default_task_status = TaskStatus.objects.get(name=self.default_options["task_status"], + project=project) + if self.issue_statuses: + project.default_issue_status = IssueStatus.objects.get(name=self.default_options["issue_status"], + project=project) + if self.issue_types: + project.default_issue_type = IssueType.objects.get(name=self.default_options["issue_type"], + project=project) + if self.priorities: + project.default_priority = Priority.objects.get(name=self.default_options["priority"], + project=project) + if self.severities: + project.default_severity = Severity.objects.get(name=self.default_options["severity"], + project=project) + + for ca in self.epic_custom_attributes: + EpicCustomAttribute.objects.create( + name=ca["name"], + description=ca["description"], + type=ca["type"], + order=ca["order"], + project=project + ) + + for ca in self.us_custom_attributes: + UserStoryCustomAttribute.objects.create( + name=ca["name"], + description=ca["description"], + type=ca["type"], + order=ca["order"], + project=project + ) + + for ca in self.task_custom_attributes: + TaskCustomAttribute.objects.create( + name=ca["name"], + description=ca["description"], + type=ca["type"], + order=ca["order"], + project=project + ) + + for ca in self.issue_custom_attributes: + IssueCustomAttribute.objects.create( + name=ca["name"], + description=ca["description"], + type=ca["type"], + order=ca["order"], + project=project + ) + + project.tags = self.tags + project.tags_colors = self.tags_colors + project.is_looking_for_people = self.is_looking_for_people + project.looking_for_people_note = self.looking_for_people_note + + return project diff --git a/taiga/projects/notifications/__init__.py b/taiga/projects/notifications/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/notifications/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/notifications/admin.py b/taiga/projects/notifications/admin.py new file mode 100644 index 000000000..d615fcf31 --- /dev/null +++ b/taiga/projects/notifications/admin.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin +from django.contrib.contenttypes.admin import GenericTabularInline +from django.contrib.admin import TabularInline + +from . import models + + +class WatchedInline(GenericTabularInline): + model = models.Watched + extra = 0 + raw_id_fields = ["project", "user"] + + +class NotifyPolicyInline(TabularInline): + model = models.NotifyPolicy + extra = 0 + readonly_fields = ("notify_level", "live_notify_level") + raw_id_fields = ["user"] diff --git a/taiga/projects/notifications/api.py b/taiga/projects/notifications/api.py new file mode 100644 index 000000000..d5c6242e7 --- /dev/null +++ b/taiga/projects/notifications/api.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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_error + +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.models import Project +from . import serializers +from . import models +from . import permissions +from . import services + + +class NotifyPolicyViewSet(ModelCrudViewSet): + serializer_class = serializers.NotifyPolicySerializer + permission_classes = (permissions.NotifyPolicyPermission,) + + def _build_needed_notify_policies(self): + projects = Project.objects.filter( + Q(owner=self.request.user) | + Q(memberships__user=self.request.user) + ).distinct() + + for project in projects: + services.create_notify_policy_if_not_exists(project, self.request.user, NotifyLevel.all) + + def get_queryset(self): + if self.request.user.is_anonymous: + return models.NotifyPolicy.objects.none() + + self._build_needed_notify_policies() + + 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_error(self.resource_model, request.user, 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() diff --git a/taiga/projects/notifications/apps.py b/taiga/projects/notifications/apps.py new file mode 100644 index 000000000..3d78dc849 --- /dev/null +++ b/taiga/projects/notifications/apps.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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) diff --git a/taiga/projects/notifications/choices.py b/taiga/projects/notifications/choices.py new file mode 100644 index 000000000..80b4b6252 --- /dev/null +++ b/taiga/projects/notifications/choices.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import enum +from django.utils.translation import gettext_lazy as _ + + +class NotifyLevel(enum.IntEnum): + involved = 1 + all = 2 + none = 3 + + +NOTIFY_LEVEL_CHOICES = ( + (NotifyLevel.involved, _("Involved")), + (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")), +) diff --git a/taiga/projects/notifications/management/commands/__init__.py b/taiga/projects/notifications/management/commands/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/notifications/management/commands/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/notifications/management/commands/send_notifications.py b/taiga/projects/notifications/management/commands/send_notifications.py new file mode 100644 index 000000000..ba650f145 --- /dev/null +++ b/taiga/projects/notifications/management/commands/send_notifications.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand + +from taiga.projects.notifications.services import send_bulk_email + +class Command(BaseCommand): + + def handle(self, *args, **options): + send_bulk_email() diff --git a/taiga/projects/notifications/migrations/0001_initial.py b/taiga/projects/notifications/migrations/0001_initial.py new file mode 100644 index 000000000..04796de9c --- /dev/null +++ b/taiga/projects/notifications/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0002_auto_20140903_0920'), + ] + + operations = [ + migrations.CreateModel( + name='NotifyPolicy', + fields=[ + ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), + ('notify_level', models.SmallIntegerField(choices=[(1, 'Not watching'), (2, 'Watching'), (3, 'Ignoring')])), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('modified_at', models.DateTimeField()), + ('project', models.ForeignKey(to='projects.Project', related_name='notify_policies', on_delete=models.CASCADE)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='notify_policies', on_delete=models.CASCADE)), + ], + options={ + 'ordering': ['created_at'], + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='notifypolicy', + unique_together=set([('project', 'user')]), + ), + ] diff --git a/taiga/projects/notifications/migrations/0002_historychangenotification.py b/taiga/projects/notifications/migrations/0002_historychangenotification.py new file mode 100644 index 000000000..b93bff177 --- /dev/null +++ b/taiga/projects/notifications/migrations/0002_historychangenotification.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0005_membership_invitation_extra_text'), + ('history', '0004_historyentry_is_hidden'), + ('notifications', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='HistoryChangeNotification', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)), + ('key', models.CharField(max_length=255, editable=False)), + ('created_datetime', models.DateTimeField(verbose_name='created date time', auto_now_add=True)), + ('updated_datetime', models.DateTimeField(verbose_name='updated date time', auto_now_add=True)), + ('history_type', models.SmallIntegerField(choices=[(1, 'Change'), (2, 'Create'), (3, 'Delete')])), + ('history_entries', models.ManyToManyField(blank=True, null=True, to='history.HistoryEntry', verbose_name='history entries', related_name='+')), + ('notify_users', models.ManyToManyField(blank=True, null=True, to=settings.AUTH_USER_MODEL, verbose_name='notify users', related_name='+')), + ('owner', models.ForeignKey(related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='owner', on_delete=models.CASCADE)), + ('project', models.ForeignKey(related_name='+', to='projects.Project', verbose_name='project', on_delete=models.CASCADE)), + ], + options={ + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/projects/notifications/migrations/0003_auto_20141029_1143.py b/taiga/projects/notifications/migrations/0003_auto_20141029_1143.py new file mode 100644 index 000000000..92c326adc --- /dev/null +++ b/taiga/projects/notifications/migrations/0003_auto_20141029_1143.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0002_historychangenotification'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='historychangenotification', + unique_together=set([('key', 'owner', 'project', 'history_type')]), + ), + ] diff --git a/taiga/projects/notifications/migrations/0004_watched.py b/taiga/projects/notifications/migrations/0004_watched.py new file mode 100644 index 000000000..dd8134142 --- /dev/null +++ b/taiga/projects/notifications/migrations/0004_watched.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0001_initial'), + ('notifications', '0003_auto_20141029_1143'), + ] + + operations = [ + migrations.CreateModel( + name='Watched', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('object_id', models.PositiveIntegerField()), + ('created_date', models.DateTimeField(verbose_name='created date', auto_now_add=True)), + ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), + ('user', models.ForeignKey(related_name='watched', verbose_name='user', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='watched', on_delete=models.CASCADE)), + + ], + options={ + 'verbose_name': 'Watched', + 'verbose_name_plural': 'Watched', + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='watched', + unique_together=set([('content_type', 'object_id', 'user', 'project')]), + ), + ] diff --git a/taiga/projects/notifications/migrations/0005_auto_20151005_1357.py b/taiga/projects/notifications/migrations/0005_auto_20151005_1357.py new file mode 100644 index 000000000..728dfc305 --- /dev/null +++ b/taiga/projects/notifications/migrations/0005_auto_20151005_1357.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ] + + operations = [ + migrations.AlterField( + model_name='historychangenotification', + name='history_entries', + field=models.ManyToManyField(verbose_name='history entries', to='history.HistoryEntry', related_name='+'), + ), + migrations.AlterField( + model_name='historychangenotification', + name='notify_users', + field=models.ManyToManyField(verbose_name='notify users', to=settings.AUTH_USER_MODEL, related_name='+'), + ), + ] diff --git a/taiga/projects/notifications/migrations/0006_auto_20151103_0954.py b/taiga/projects/notifications/migrations/0006_auto_20151103_0954.py new file mode 100644 index 000000000..c9bf55471 --- /dev/null +++ b/taiga/projects/notifications/migrations/0006_auto_20151103_0954.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0005_auto_20151005_1357'), + ] + + operations = [ + migrations.AlterField( + model_name='notifypolicy', + name='notify_level', + field=models.SmallIntegerField(choices=[(1, 'Involved'), (2, 'All'), (3, 'None')]), + ), + ] diff --git a/taiga/projects/notifications/migrations/0007_notifypolicy_live_notify_level.py b/taiga/projects/notifications/migrations/0007_notifypolicy_live_notify_level.py new file mode 100644 index 000000000..24ab5427d --- /dev/null +++ b/taiga/projects/notifications/migrations/0007_notifypolicy_live_notify_level.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.6 on 2017-03-31 13:03 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.projects.notifications.choices + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0006_auto_20151103_0954'), + ] + + operations = [ + migrations.AddField( + model_name='notifypolicy', + name='live_notify_level', + field=models.SmallIntegerField(choices=[(taiga.projects.notifications.choices.NotifyLevel(1), 'Involved'), (taiga.projects.notifications.choices.NotifyLevel(2), 'All'), (taiga.projects.notifications.choices.NotifyLevel(3), 'None')], default=taiga.projects.notifications.choices.NotifyLevel(1)), + ), + ] diff --git a/taiga/projects/notifications/migrations/0008_auto_20181010_1124.py b/taiga/projects/notifications/migrations/0008_auto_20181010_1124.py new file mode 100644 index 000000000..fe3e9237e --- /dev/null +++ b/taiga/projects/notifications/migrations/0008_auto_20181010_1124.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# 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), + ), + ] diff --git a/taiga/projects/notifications/migrations/0009_auto_20200615_0811.py b/taiga/projects/notifications/migrations/0009_auto_20200615_0811.py new file mode 100644 index 000000000..d01756318 --- /dev/null +++ b/taiga/projects/notifications/migrations/0009_auto_20200615_0811.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-15 08:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0008_auto_20181010_1124'), + ] + + operations = [ + migrations.AlterField( + model_name='notifypolicy', + name='web_notify_level', + field=models.BooleanField(blank=True, default=True), + ), + ] diff --git a/taiga/projects/notifications/migrations/__init__.py b/taiga/projects/notifications/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/notifications/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/notifications/mixins.py b/taiga/projects/notifications/mixins.py new file mode 100644 index 000000000..2a9816381 --- /dev/null +++ b/taiga/projects/notifications/mixins.py @@ -0,0 +1,416 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from functools import partial +from operator import is_not + +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist + +from taiga.base import response +from taiga.base.decorators import detail_route +from taiga.base.api import serializers +from taiga.base.api.utils import get_object_or_error +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 + + +class WatchedResourceMixin: + """ + Rest Framework resource mixin for resources susceptible + to be notifiable about their changes. + + NOTE: + - this mixin has hard dependency on HistoryMixin + defined on history app and should be located always + after it on inheritance definition. + + - the classes using this mixing must have a method: + def pre_conditions_on_save(self, obj) + """ + + _not_notify = False + _old_watchers = None + _old_mentions = [] + + @detail_route(methods=["POST"]) + def watch(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "watch", obj) + self.pre_conditions_on_save(obj) + services.add_watcher(obj, request.user) + return response.Ok() + + @detail_route(methods=["POST"]) + def unwatch(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "unwatch", obj) + self.pre_conditions_on_save(obj) + services.remove_watcher(obj, request.user) + return response.Ok() + + def send_notifications(self, obj, history=None): + """ + Shortcut method for resources with special save + cases on actions methods that not uses standard + `post_save` hook of drf resources. + """ + if history is None: + history = self.get_last_history() + + # If not history found, or it is empty. Do notthing. + if not history: + return + + if self._not_notify: + return + + obj = self.get_object_for_snapshot(obj) + + # Process that analizes the corresponding diff and + # 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) + + # 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): + if not getattr(self, 'object', None): + self.object = self.get_object_or_none() + + obj = self.object + if obj and obj.id: + if hasattr(obj, "watchers"): + self._old_watchers = [ + watcher.id for watcher in obj.get_watchers() + ] + + mention_fields = ['description', 'content'] + for field_name in mention_fields: + if not hasattr(obj, field_name) or not hasattr(obj, "get_project"): + continue + self._old_mentions += services.get_mentions(obj.get_project(), getattr(obj, field_name)) + + 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) + + def pre_delete(self, obj): + 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'] + new_mentions = [] + for field_name in mention_fields: + if not hasattr(obj, field_name) or not hasattr(obj, "get_project"): + continue + value = self.request.DATA.get(field_name) + if not value: + continue + new_mentions += services.get_mentions(obj.get_project(), value) + + return new_mentions + + def _get_mentions_in_comment(self, obj): + comment = self.request.DATA.get('comment') + if not comment or not hasattr(obj, "get_project"): + return [] + return services.get_mentions(obj.get_project(), comment) + + +class WatchedModelMixin(object): + """ + Generic model mixin that makes model compatible + with notification system. + + NOTE: is mandatory extend your model class with + this mixin if you want send notifications about + your model class. + """ + + def get_project(self) -> object: + """ + Default implementation method for obtain a project + instance from current object. + + It comes with generic default implementation + that should works in almost all cases. + """ + return self.project + + def get_watchers(self) -> object: + """ + Default implementation method for obtain a list of + watchers for current instance. + """ + return services.get_watchers(self) + + def get_related_people(self) -> object: + """ + Default implementation for obtain the related people of + current instance. + """ + return services.get_related_people(self) + + def get_watched(self, user_or_id): + return services.get_watched(user_or_id, type(self)) + + def add_watcher(self, user): + services.add_watcher(self, user) + + def remove_watcher(self, user): + services.remove_watcher(self, user) + + def get_owner(self) -> object: + """ + Default implementation for obtain the owner of + current instance. + """ + return self.owner + + def get_assigned_to(self) -> object: + """ + Default implementation for obtain the assigned + user. + """ + if hasattr(self, "assigned_to"): + return self.assigned_to + return None + + def get_participants(self) -> frozenset: + """ + Default implementation for obtain the list + of participans. It is mainly the owner and + assigned user. + """ + participants = (self.get_assigned_to(), + self.get_owner(),) + is_not_none = partial(is_not, None) + return frozenset(filter(is_not_none, participants)) + + +class WatchedResourceSerializer(serializers.LightSerializer): + is_watcher = MethodField() + total_watchers = MethodField() + + def get_is_watcher(self, obj): + # The "is_watcher" attribute is attached in the get_queryset of the viewset. + if "request" in self.context: + user = self.context["request"].user + return user.is_authenticated and getattr(obj, "is_watcher", False) + + return False + + def get_total_watchers(self, obj): + # The "total_watchers" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "total_watchers", 0) or 0 + + +class EditableWatchedResourceSerializer(serializers.ModelSerializer): + watchers = WatchersField(required=False) + + def restore_object(self, attrs, instance=None): + # watchers is not a field from the model but can be attached in the get_queryset of the viewset. + # If that's the case we need to remove it before calling the super method + self.fields.pop("watchers", None) + self.validate_watchers(attrs, "watchers") + new_watcher_ids = attrs.pop("watchers", None) + obj = super(EditableWatchedResourceSerializer, self).restore_object(attrs, instance) + + # A partial update can exclude the watchers field or if the new instance can still not be saved + if instance is None or new_watcher_ids is None: + return obj + + new_watcher_ids = set(new_watcher_ids) + old_watcher_ids = set(obj.get_watchers().values_list("id", flat=True)) + adding_watcher_ids = list(new_watcher_ids.difference(old_watcher_ids)) + removing_watcher_ids = list(old_watcher_ids.difference(new_watcher_ids)) + + adding_users = get_user_model().objects.filter(id__in=adding_watcher_ids) + removing_users = get_user_model().objects.filter(id__in=removing_watcher_ids) + for user in adding_users: + services.add_watcher(obj, user) + + for user in removing_users: + services.remove_watcher(obj, user) + + obj.watchers = obj.get_watchers() + + return obj + + def to_native(self, obj): + # if watchers wasn't attached via the get_queryset of the viewset we need to manually add it + if obj is not None: + if not hasattr(obj, "watchers"): + obj.watchers = [user.id for user in obj.get_watchers()] + + request = self.context.get("request", None) + user = request.user if request else None + if user and user.is_authenticated: + obj.is_watcher = user.id in obj.watchers + + return super(WatchedResourceSerializer, self).to_native(obj) + + def save(self, **kwargs): + obj = super(EditableWatchedResourceSerializer, self).save(**kwargs) + self.fields["watchers"] = WatchersField(required=False) + obj.watchers = [user.id for user in obj.get_watchers()] + return obj + + +class WatchersViewSetMixin: + # Is a ModelListViewSet with two required params: permission_classes and resource_model + serializer_class = WatcherSerializer + list_serializer_class = WatcherSerializer + permission_classes = None + resource_model = None + + def retrieve(self, request, *args, **kwargs): + pk = kwargs.get("pk", None) + resource_id = kwargs.get("resource_id", None) + resource = get_object_or_error(self.resource_model, request.user, pk=resource_id) + + self.check_permissions(request, 'retrieve', resource) + + try: + self.object = resource.get_watchers().get(pk=pk) + except ObjectDoesNotExist: # or User.DoesNotExist + return response.NotFound() + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + def list(self, request, *args, **kwargs): + resource_id = kwargs.get("resource_id", None) + resource = get_object_or_error(self.resource_model, request.user, pk=resource_id) + + self.check_permissions(request, 'list', resource) + + return super().list(request, *args, **kwargs) + + 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: + def update(self, request, *args, **kwargs): + if not request.DATA.get('assigned_users'): + return super().update(request, *args, **kwargs) + + if not self.object: + self.object = self.get_object_or_none() + obj = self.object + + old_assigned_users = [user for user in obj.assigned_users.all()].copy() + old_assigned_to = obj.assigned_to if obj.assigned_to else None + + result = super().update(request, *args, **kwargs) + + new_assigned_users = [ + user for user in result.data.get('assigned_users', []) + if user not in old_assigned_users + and user != old_assigned_to + and user != self.request.user + ] if 200 <= result.status_code < 300 else [] + + if len(new_assigned_users): + signal_assigned_users.send(sender=self.__class__, + user=self.request.user, + obj=obj, + new_assigned_users=new_assigned_users) + return result diff --git a/taiga/projects/notifications/models.py b/taiga/projects/notifications/models.py new file mode 100644 index 000000000..510cc160d --- /dev/null +++ b/taiga/projects/notifications/models.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey + +from django.db import models +from django.utils.translation import gettext_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 + + +class NotifyPolicy(models.Model): + """ + This class represents a persistence for + project user notifications preference. + """ + project = models.ForeignKey("projects.Project", related_name="notify_policies", on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="notify_policies", on_delete=models.CASCADE) + 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() + _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) + + +class HistoryChangeNotification(models.Model): + """ + This class controls the pending notifications for an object, it should be instantiated + or updated when an object requires notifications. + """ + key = models.CharField(max_length=255, unique=False, editable=False) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=False, + blank=False, + verbose_name=_("owner"), + related_name="+", + on_delete=models.CASCADE, + ) + created_datetime = models.DateTimeField(null=False, blank=False, auto_now_add=True, + verbose_name=_("created date time")) + updated_datetime = models.DateTimeField(null=False, blank=False, auto_now_add=True, + verbose_name=_("updated date time")) + history_entries = models.ManyToManyField("history.HistoryEntry", + verbose_name=_("history entries"), + related_name="+") + notify_users = models.ManyToManyField(settings.AUTH_USER_MODEL, + verbose_name=_("notify users"), + related_name="+") + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + verbose_name=_("project"), + related_name="+", + on_delete=models.CASCADE, + ) + + history_type = models.SmallIntegerField(choices=HISTORY_TYPE_CHOICES) + + class Meta: + unique_together = ("key", "owner", "project", "history_type") + + +class Watched(models.Model): + content_type = models.ForeignKey("contenttypes.ContentType", on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=False, + null=False, + related_name="watched", + verbose_name=_("user"), + on_delete=models.CASCADE + ) + created_date = models.DateTimeField(auto_now_add=True, null=False, blank=False, + verbose_name=_("created date")) + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + verbose_name=_("project"), + related_name="watched", + on_delete=models.CASCADE + ) + class Meta: + 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", + on_delete=models.CASCADE + ) + event_type = models.PositiveIntegerField() + data = JSONField() diff --git a/taiga/projects/notifications/permissions.py b/taiga/projects/notifications/permissions.py new file mode 100644 index 000000000..b3f81b529 --- /dev/null +++ b/taiga/projects/notifications/permissions.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (TaigaResourcePermission, IsAuthenticated) + + +class NotifyPolicyPermission(TaigaResourcePermission): + retrieve_perms = IsAuthenticated() + create_perms = IsAuthenticated() + update_perms = IsAuthenticated() + partial_update_perms = IsAuthenticated() + destroy_perms = IsAuthenticated() + list_perms = IsAuthenticated() diff --git a/taiga/projects/notifications/serializers.py b/taiga/projects/notifications/serializers.py new file mode 100644 index 000000000..160179b82 --- /dev/null +++ b/taiga/projects/notifications/serializers.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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 + + +class NotifyPolicySerializer(serializers.ModelSerializer): + project_name = serializers.SerializerMethodField("get_project_name") + + class Meta: + model = models.NotifyPolicy + fields = ('id', 'project', 'project_name', 'notify_level', + 'live_notify_level', 'web_notify_level') + + def get_project_name(self, obj): + return obj.project.name + + +class WatcherSerializer(serializers.ModelSerializer): + full_name = serializers.CharField(source='get_full_name', required=False) + + 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() diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py new file mode 100644 index 000000000..d18b99ca5 --- /dev/null +++ b/taiga/projects/notifications/services.py @@ -0,0 +1,585 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime + +from functools import partial +import logging + +from django.apps import apps +from django.db import IntegrityError, transaction +from django.db.models import Q +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth import get_user_model +from django.utils import timezone +from django.utils.translation import gettext as _ + +from taiga.base import exceptions as exc +from taiga.base.utils.iterators import iter_queryset +from taiga.base.mails import InlineCSSTemplateMail +from taiga.front.templatetags.functions import resolve as resolve_front_url +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import HistoryChangeNotification +from taiga.projects.history.choices import HistoryType +from taiga.projects.history.services import (make_key_from_model_object, + get_last_snapshot_for_key, + get_model_from_key) +from taiga.permissions.services import user_has_perm +from taiga.events import events + +from django_pglocks import advisory_lock + +from .models import HistoryChangeNotification, Watched +from .squashing import squash_history_entries + +logger = logging.getLogger(__name__) + +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 + and user. + """ + model_cls = apps.get_model("notifications", "NotifyPolicy") + qs = model_cls.objects.filter(project=project, + user=user) + return qs.exists() + + +def create_notify_policy(project, user, level=NotifyLevel.involved, + live_level=NotifyLevel.involved): + """ + Given a project and user, create notification policy for it. + """ + model_cls = apps.get_model("notifications", "NotifyPolicy") + try: + return model_cls.objects.create(project=project, + user=user, + notify_level=level, + live_notify_level=live_level) + except IntegrityError as e: + raise exc.IntegrityError( + _("Notify exists for specified user and project")) from e + + +def create_notify_policy_if_not_exists(project, user, + level=NotifyLevel.involved, + live_level=NotifyLevel.involved, + web_level=True): + """ + Given a project and user, create notification policy for it. + """ + model_cls = apps.get_model("notifications", "NotifyPolicy") + try: + result = model_cls.objects.get_or_create( + project=project, + user=user, + defaults={ + "notify_level": level, + "live_notify_level": live_level, + "web_notify_level": web_level + } + ) + return result[0] + except IntegrityError as e: + raise exc.IntegrityError( + _("Notify exists for specified user and project")) from e + + +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, "add_watcher"): + return + + # Adding the person who edited the object to the watchers + if comment and not user.is_system: + obj.add_watcher(user) + + mentions = get_object_mentions(obj, comment) or [] + for mentioned in mentions: + obj.add_watcher(mentioned) + + +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(project, text) + + return data.get("mentions") + + +def _filter_by_permissions(obj, user): + UserStory = apps.get_model("userstories", "UserStory") + Issue = apps.get_model("issues", "Issue") + Task = apps.get_model("tasks", "Task") + Epic = apps.get_model("epics", "Epic") + WikiPage = apps.get_model("wiki", "WikiPage") + + if isinstance(obj, UserStory): + return user_has_perm(user, "view_us", obj, cache="project") + elif isinstance(obj, Issue): + return user_has_perm(user, "view_issues", obj, cache="project") + elif isinstance(obj, Task): + return user_has_perm(user, "view_tasks", obj, cache="project") + elif isinstance(obj, Epic): + return user_has_perm(user, "view_epics", obj, cache="project") + elif isinstance(obj, WikiPage): + return user_has_perm(user, "view_wiki_pages", obj, cache="project") + return False + + +def _filter_notificable(user): + return user.is_active and not user.is_system + + +def get_users_to_notify(obj, *, history=None, discard_users=None, live=False) -> list: + """ + Get filtered set of users to notify for specified + model instance and changer. + + NOTE: changer at this momment is not used. + NOTE: analogouts to obj.get_watchers_to_notify(changer) + """ + project = obj.get_project() + + def _check_level(project: object, user: object, levels: tuple) -> bool: + policy = project.cached_notify_policy_for_user(user) + if live: + return policy.live_notify_level in levels + return policy.notify_level in levels + + _can_notify_hard = partial(_check_level, project, + levels=[NotifyLevel.all]) + _can_notify_light = partial(_check_level, project, + levels=[NotifyLevel.all, NotifyLevel.involved]) + + candidates = set() + candidates.update(filter(_can_notify_hard, project.members.all())) + candidates.update(filter(_can_notify_hard, obj.project.get_watchers())) + candidates.update(filter(_can_notify_light, obj.get_watchers())) + candidates.update(filter(_can_notify_light, obj.get_participants())) + + # If the history is an unassignment change we should notify that user too + user_ids = [] + if history and history.type == HistoryType.change and "assigned_to" in history.diff: + user_ids = [user_id for user_id in history.diff["assigned_to"] if isinstance(user_id, int)] + + if user_ids: + assigned_to_users = get_user_model().objects.filter(id__in=user_ids) + candidates.update(filter(_can_notify_light, assigned_to_users)) + + # Remove the changer from candidates + if discard_users: + candidates = candidates - set(discard_users) + + # Filter by object permissions + candidates = set(filter(partial(_filter_by_permissions, obj), candidates)) + + # Filter disabled and system users + candidates = set(filter(partial(_filter_notificable), candidates)) + + return frozenset(candidates) + + +def _resolve_template_name(model: object, *, change_type: int) -> str: + """ + Ginven an changed model instance and change type, + return the preformated template name for it. + """ + ct = ContentType.objects.get_for_model(model) + # Resolve integer enum value from "change_type" + # parameter to human readable string + if change_type == HistoryType.create: + change_type = "create" + elif change_type == HistoryType.change: + change_type = "change" + else: + change_type = "delete" + tmpl = "{app_label}/{model}-{change}" + return tmpl.format(app_label=ct.app_label, + model=ct.model, + change=change_type) + + +def _make_template_mail(name: str): + """ + Helper that creates a adhoc djmail template email + instance for specified name, and return an instance + of it. + """ + cls = type("InlineCSSTemplateMail", + (InlineCSSTemplateMail,), + {"name": name}) + + return cls() + + +@transaction.atomic +def send_notifications(obj, *, history): + if history.is_hidden: + return None + + key = make_key_from_model_object(obj) + owner = get_user_model().objects.get(pk=history.user["pk"]) + notification, created = (HistoryChangeNotification.objects.select_for_update() + .get_or_create(key=key, + owner=owner, + project=obj.project, + history_type=history.type)) + notification.updated_datetime = timezone.now() + notification.save() + notification.history_entries.add(history) + + # Get a complete list of notifiable users for current + # object and send the change notification to them. + notify_users = get_users_to_notify(obj, history=history, discard_users=[notification.owner]) + notification.notify_users.add(*notify_users) + + # If we are the min interval is 0 it just work in a synchronous and spamming way + if settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL == 0: + send_sync_notifications(notification.id) + + live_notify_users = get_users_to_notify(obj, history=history, discard_users=[notification.owner], live=True) + for user in live_notify_users: + events.emit_live_notification_for_model(obj, user, history) + + +@transaction.atomic +def send_sync_notifications(notification_id): + """ + Given changed instance, calculate the history entry and + a complete list for users to notify, send + email to all users. + """ + + notification = HistoryChangeNotification.objects.select_for_update().get(pk=notification_id) + + # Custom Hardcode Filter + if settings.NOTIFICATIONS_CUSTOM_FILTER: + allowed_keys = [ + "userstories.userstory", + "epics.epic", + "issues.issue", + ] + + if not any([(notification.key.find(key) >= 0) for key in allowed_keys]): + notification.delete() + return False, [] + + # If the last modification is too recent we ignore it for the time being + now = timezone.now() + time_diff = now - notification.updated_datetime + if time_diff.seconds < settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL: + return False, [] + + # Custom Hardcode Filter + qs = notification.history_entries + if settings.NOTIFICATIONS_CUSTOM_FILTER: + queries = [ + ~Q(comment=""), + Q(key__startswith="epics.epic", type=HistoryType.create), + Q(Q(key__startswith="userstories.userstory"), ~Q(values__users={}), ~Q(values__users=[])), + Q(Q(key__startswith="issues.issue"), ~Q(values__users={}), ~Q(values__users=[])), + ] + query = queries.pop() + for item in queries: + query |= item + + qs = qs.filter(query).order_by("created_at") + + else: + qs = qs.all() + + history_entries = tuple(qs) + history_entries = list(squash_history_entries(history_entries)) + + # If there are no effective modifications we can delete this notification + # without further processing + if notification.history_type == HistoryType.change and not history_entries: + notification.delete() + return False, [] + + obj, _ = get_last_snapshot_for_key(notification.key) + obj_class = get_model_from_key(obj.key) + + context = {"obj_class": obj_class, + "snapshot": obj.snapshot, + "project": notification.project, + "changer": notification.owner, + "history_entries": history_entries} + + model = get_model_from_key(notification.key) + template_name = _resolve_template_name(model, change_type=notification.history_type) + email = _make_template_mail(template_name) + domain = settings.SITES["api"]["domain"].split(":")[0] or settings.SITES["api"]["domain"] + + if "ref" in obj.snapshot: + msg_id = obj.snapshot["ref"] + elif "slug" in obj.snapshot: + msg_id = obj.snapshot["slug"] + else: + 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": project_name, + "msg_id": msg_id, + "time": int(now.timestamp()), + "domain": domain + } + + headers = { + "Message-ID": "<{project_slug}/{msg_id}/{time}@{domain}>".format(**format_args), + "In-Reply-To": "<{project_slug}/{msg_id}@{domain}>".format(**format_args), + "References": "<{project_slug}/{msg_id}@{domain}>".format(**format_args), + "List-ID": 'Taiga/{project_name} '.format(**format_args), + "Thread-Index": make_ms_thread_index("<{project_slug}/{msg_id}@{domain}>".format(**format_args), now), + "List-Unsubscribe": "<{unsubscribe_url}>".format(**format_args), + } + + for user in notification.notify_users.distinct(): + context["user"] = user + context["lang"] = user.lang or settings.LANGUAGE_CODE + try: + email.send(user.email, context, headers=headers) + except Exception: + """ + Catch all smtp exceptions: + + - smtplib.SMTPDataError + - smtplib.SMTPException + - smtplib.SMTPServerDisconnected + - ssl.SSLError + - OSError + - ValueError + - ... + """ + logger.exception("Error sending email notifications") + + notification_id = notification.id + notification.delete() + return notification_id, history_entries + + +def process_sync_notifications(): + for notification in HistoryChangeNotification.objects.all(): + send_sync_notifications(notification.pk) + + +def _get_q_watchers(obj): + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + return Q(watched__content_type=obj_type, watched__object_id=obj.id) + + +def get_watchers(obj): + """Get the watchers of an object. + + :param obj: Any Django model instance. + + :return: User queryset object representing the users that watch the object. + """ + return get_user_model().objects.filter(_get_q_watchers(obj)) + + +def get_related_people(obj): + """Get the related people of an object for notifications. + + :param obj: Any Django model instance. + + :return: User queryset object representing the users related to the object. + """ + + ## - Watchers + related_people_ids = list(get_watchers(obj).values_list('id', flat=True)) + + ## - Owner + if hasattr(obj, "owner_id") and obj.owner_id: + related_people_ids.append(obj.owner_id) + + ## - Assigned to + if hasattr(obj, "assigned_to_id") and obj.assigned_to_id: + related_people_ids.append(obj.assigned_to_id) + + ## - Apply filters + related_people = get_user_model().objects.filter(id__in=set(related_people_ids)) + + ## - Exclude inactive and system users and remove duplicate + related_people = related_people.exclude(is_active=False) + related_people = related_people.exclude(is_system=True) + + return related_people + + +def get_watched(user_or_id, model): + """Get the objects watched by an user. + + :param user_or_id: :class:`~taiga.users.models.User` instance or id. + :param model: Show only objects of this kind. Can be any Django model class. + + :return: Queryset of objects representing the votes of the user. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + conditions = ('notifications_watched.content_type_id = %s', + '%s.id = notifications_watched.object_id' % model._meta.db_table, + 'notifications_watched.user_id = %s') + + if isinstance(user_or_id, get_user_model()): + user_id = user_or_id.id + else: + user_id = user_or_id + + return model.objects.extra(where=conditions, tables=('notifications_watched',), + params=(obj_type.id, user_id)) + + +def get_projects_watched(user_or_id): + """Get the objects watched by an user. + + :param user_or_id: :class:`~taiga.users.models.User` instance or id. + :param model: Show only objects of this kind. Can be any Django model class. + + :return: Queryset of objects representing the votes of the user. + """ + + if isinstance(user_or_id, get_user_model()): + user = user_or_id + else: + user = get_user_model().objects.get(id=user_or_id) + + project_class = apps.get_model("projects", "Project") + project_ids = (user.notify_policies.exclude(notify_level=NotifyLevel.none) + .values_list("project__id", flat=True)) + return project_class.objects.filter(id__in=project_ids) + + +def add_watcher(obj, user): + """Add a watcher to an object. + + If the user is already watching the object nothing happents (except if there is a level update), + so this function can be considered idempotent. + + :param obj: Any Django model instance. + :param user: User adding the watch. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + watched, created = Watched.objects.get_or_create( + content_type=obj_type, + object_id=obj.id, + user=user, + project=obj.project) + + notify_policy, _ = apps.get_model("notifications", "NotifyPolicy").objects.get_or_create( + project=obj.project, + user=user, + defaults={"notify_level": NotifyLevel.involved, + "live_notify_level": NotifyLevel.involved} + ) + + return watched + + +def remove_watcher(obj, user): + """Remove an watching user from an object. + + If the user has not watched the object nothing happens so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User removing the watch. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + qs = Watched.objects.filter(content_type=obj_type, object_id=obj.id, user=user) + if not qs.exists(): + return + + qs.delete() + + +def set_notify_policy_level(notify_policy, notify_level, live=False): + """ + Set notification level for specified policy. + """ + if notify_level not in [e.value for e in NotifyLevel]: + raise exc.IntegrityError(_("Invalid value for notify level")) + + if live: + notify_policy.live_notify_level = notify_level + else: + notify_policy.notify_level = notify_level + notify_policy.save() + + +def set_notify_policy_level_to_ignore(notify_policy, live=False): + """ + Set notification level for specified policy. + """ + set_notify_policy_level(notify_policy, NotifyLevel.none, live=live) + + +def make_ms_thread_index(msg_id, dt): + """ + Create the 22-byte base of the thread-index string in the format: + + 6 bytes = First 6 significant bytes of the FILETIME stamp + 16 bytes = GUID (we're using a md5 hash of the message id) + + See http://www.meridiandiscovery.com/how-to/e-mail-conversation-index-metadata-computer-forensics/ + """ + + import base64 + import hashlib + import struct + + # Convert to FILETIME epoch (microseconds since 1601) + delta = datetime.date(1970, 1, 1) - datetime.date(1601, 1, 1) + filetime = int(dt.timestamp() + delta.total_seconds()) * 10000000 + + # only want the first 6 bytes + thread_bin = struct.pack(">Q", filetime)[:6] + + # Make a guid. This is usually generated by Outlook. + # The format is usually >IHHQ, but we don't care since it's just a hash of the id + md5 = hashlib.md5(msg_id.encode('utf-8')) + thread_bin += md5.digest() + + # base64 encode + return base64.b64encode(thread_bin).decode("utf-8") + + +def send_bulk_email(): + with advisory_lock("send-notifications-command", wait=False) as acquired: + if acquired: + qs = HistoryChangeNotification.objects.all().order_by("-id") + for change_notification in iter_queryset(qs, itersize=100): + try: + send_sync_notifications(change_notification.pk) + except HistoryChangeNotification.DoesNotExist: + pass diff --git a/taiga/projects/notifications/signals.py b/taiga/projects/notifications/signals.py new file mode 100644 index 000000000..816baae98 --- /dev/null +++ b/taiga/projects/notifications/signals.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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_id 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) diff --git a/taiga/projects/notifications/squashing.py b/taiga/projects/notifications/squashing.py new file mode 100644 index 000000000..88392667f --- /dev/null +++ b/taiga/projects/notifications/squashing.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from collections import namedtuple, OrderedDict + + +HistoryEntry = namedtuple('HistoryEntry', 'comment values_diff') + + +# These fields are ignored + +EXCLUDED_FIELDS = ( + 'description', + 'description_html', + 'blocked_note', + 'blocked_note_html', + 'content', + 'content_html', + 'epics_order', + 'backlog_order', + 'kanban_order', + 'sprint_order', + 'taskboard_order', + 'us_order', + 'custom_attributes', + 'tribe_gig', +) + +# These fields can't be squashed because we don't have +# a squashing algorithm yet. + +NON_SQUASHABLE_FIELDS = ( + 'points', + 'attachments', + 'watchers', + 'description_diff', + 'content_diff', + 'blocked_note_diff', + 'custom_attributes', +) + + +def is_squashable(field): + return field not in EXCLUDED_FIELDS and field not in NON_SQUASHABLE_FIELDS + + +def summary(field, entries): + """ + Given an iterable of HistoryEntry of the same type return a summarized list. + """ + if len(entries) <= 1: + return entries + + # Apply squashing algorithm. In this case, get first `from` and last `to`. + initial = entries[0].values_diff[field] + final = entries[-1].values_diff[field] + from_, to = initial[0], final[1] + + # If the resulting squashed `from` and `to` are equal we can skip + # this entry completely + + return [] if from_ == to else [HistoryEntry('', {field: [from_, to]})] + + +def squash_history_entries(history_entries): + """ + Given an iterable of HistoryEntry, squash them summarizing entries that have + a squashable algorithm available. + """ + history_entries = (HistoryEntry(entry.comment, entry.values_diff) for entry in history_entries) + grouped = OrderedDict() + for entry in history_entries: + if entry.comment: + yield entry + continue + + for field, diff in entry.values_diff.items(): + if is_squashable(field): + grouped.setdefault(field, []) + grouped[field].append(HistoryEntry('', {field: diff})) + else: + yield HistoryEntry('', {field: diff}) + + for field, entries in grouped.items(): + squashed = summary(field, entries) + for entry in squashed: + yield entry diff --git a/taiga/projects/notifications/tasks.py b/taiga/projects/notifications/tasks.py new file mode 100644 index 000000000..1002bf0ef --- /dev/null +++ b/taiga/projects/notifications/tasks.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.celery import app + +from . import services + + +@app.task() +def send_bulk_email(): + services.send_bulk_email() diff --git a/taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja b/taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja new file mode 100644 index 000000000..7ce15af3b --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-change-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-html.jinja" %} + +{% block head %} + {% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %} +

Epic updated

+

Hello {{ user }},
{{ changer }} has updated a epic on {{ project }}

+

Epic #{{ ref }} {{ subject }}

+ See epic + {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja b/taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja new file mode 100644 index 000000000..f8883917b --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-change-body-text.jinja @@ -0,0 +1,16 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %} +Epic updated +Hello {{ user }}, {{ changer }} has updated a epic on {{ project }} +See epic #{{ ref }} {{ subject }} at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja b/taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja new file mode 100644 index 000000000..94d20cc76 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-change-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Updated the epic #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja b/taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja new file mode 100644 index 000000000..6817a4004 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-create-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %} +

New epic created

+

Hello {{ user }},
{{ changer }} has created a new epic on {{ project }}

+

Epic #{{ ref }} {{ subject }}

+ See epic +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja b/taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja new file mode 100644 index 000000000..3ff51ee67 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-create-body-text.jinja @@ -0,0 +1,16 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("epic", project.slug, snapshot.ref) %} +New epic created +Hello {{ user }}, {{ changer }} has created a new epic on {{ project }} +See epic #{{ ref }} {{ subject }} at {{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja b/taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja new file mode 100644 index 000000000..a0ddd44fd --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-create-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Created the epic #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja new file mode 100644 index 000000000..54f8cd945 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-delete-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +

Epic deleted

+

Hello {{ user }},
{{ changer }} has deleted a epic on {{ project }}

+

Epic #{{ ref }} {{ subject }}

+

{{ signature }}

+ {% endtrans %} +{% endblock %} + diff --git a/taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja new file mode 100644 index 000000000..f72776eab --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-delete-body-text.jinja @@ -0,0 +1,16 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +Epic deleted +Hello {{ user }}, {{ changer }} has deleted a epic on {{ project }} +Epic #{{ ref }} {{ subject }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja b/taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja new file mode 100644 index 000000000..13487eccc --- /dev/null +++ b/taiga/projects/notifications/templates/emails/epics/epic-delete-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Deleted the epic #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja b/taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja new file mode 100644 index 000000000..ee91c29ed --- /dev/null +++ b/taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-html.jinja" %} + +{% block head %} + {% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("issue", project.slug, snapshot.ref) %} +

Issue updated on {{ project }}

+

Hello {{ user }},
{{ changer }} has updated an issue:

+

#{{ ref }} {{ subject }}

+ See issue + {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja b/taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja new file mode 100644 index 000000000..b31e744b8 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("issue", project.slug, snapshot.ref) %} +Issue updated on {{ project }} + +Hello {{ user }}, {{ changer }} has updated an issue: + +#{{ ref }} {{ subject }} + +See issue at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja b/taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja new file mode 100644 index 000000000..46a33a111 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Updated the issue #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja b/taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja new file mode 100644 index 000000000..fca1344e9 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("issue", project.slug, snapshot.ref) %} +

New issue created on {{ project }}

+

Hello {{ user }},
{{ changer }} has created a new issue:

+

#{{ ref }} {{ subject }}

+ See issue +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja b/taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja new file mode 100644 index 000000000..66652ef7c --- /dev/null +++ b/taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("issue", project.slug, snapshot.ref) %} +New issue created on {{ project }} + +Hello {{ user }}, {{ changer }} has created a new issue: + +#{{ ref }} {{ subject }} + +See issue at {{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja b/taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja new file mode 100644 index 000000000..11ec65b88 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Created the issue #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja new file mode 100644 index 000000000..56d5e46d1 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +

Issue deleted on {{ project }}

+

Hello {{ user }},
{{ changer }} has deleted an issue:

+

#{{ ref }} {{ subject }}

+

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja new file mode 100644 index 000000000..4484eee85 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +Issue deleted on {{ project }} + +Hello {{ user }}, {{ changer }} has deleted an issue: + +#{{ ref }} {{ subject }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja b/taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja new file mode 100644 index 000000000..9b44f242f --- /dev/null +++ b/taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Deleted the issue #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja new file mode 100644 index 000000000..4e5a21d17 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-html.jinja" %} + +{% block head %} + {% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, name=snapshot.name, url=resolve_front_url("taskboard", project.slug, snapshot.slug) %} +

Sprint updated on {{ project }}

+

Hello {{ user }},
{{ changer }} has updated a sprint:

+

{{ name }}

+ See sprint + {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja new file mode 100644 index 000000000..f6a86ccb4 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, name=snapshot.name, url=resolve_front_url("task", project.slug, snapshot.slug) %} +Sprint updated on {{ project }} + +Hello {{ user }}, {{ changer }} has updated a sprint: + +{{ name }} + +See sprint at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja new file mode 100644 index 000000000..bb2e5e715 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, milestone=snapshot.name|safe %} +[{{ project }}] Updated the sprint "{{ milestone }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja new file mode 100644 index 000000000..7ed86acdb --- /dev/null +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, name=snapshot.name, url=resolve_front_url("taskboard", project.slug, snapshot.slug) %} +

New sprint created on {{ project }}

+

Hello {{ user }},
{{ changer }} has created a new sprint

+

{{ name }}

+ See sprint +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja new file mode 100644 index 000000000..cc1d19a83 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, name=snapshot.name, url=resolve_front_url("taskboard", project.slug, snapshot.slug) %} +New sprint created on {{ project }} + +Hello {{ user }}, {{ changer }} has created a new sprint: + +{{ name }} + +See sprint at {{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja new file mode 100644 index 000000000..2aab4b9f0 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, milestone=snapshot.name|safe %} +[{{ project }}] Created the sprint "{{ milestone }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja new file mode 100644 index 000000000..335839e76 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, name=snapshot.name %} +

Sprint deleted on {{ project }}

+

Hello {{ user }},
{{ changer }} has deleted an sprint:

+

{{ name }}

+

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja new file mode 100644 index 000000000..3e5686ae5 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, name=snapshot.name %} +Sprint deleted on {{ project }} + +Hello {{ user }}, {{ changer }} has deleted a sprint: + +{{ name }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja new file mode 100644 index 000000000..c6c91cbb0 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, milestone=snapshot.name|safe %} +[{{ project }}] Deleted the Sprint "{{ milestone }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja b/taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja new file mode 100644 index 000000000..108694ad4 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-html.jinja" %} + +{% block head %} + {% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("task", project.slug, snapshot.ref) %} +

Task updated on {{ project }}

+

Hello {{ user }},
{{ changer }} has updated a task:

+

#{{ ref }} {{ subject }}

+ See task + {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja b/taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja new file mode 100644 index 000000000..699088b6a --- /dev/null +++ b/taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("task", project.slug, snapshot.ref) %} +Task updated on {{ project }} + +Hello {{ user }}, {{ changer }} has updated a task: + +#{{ ref }} {{ subject }} + +See task at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja b/taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja new file mode 100644 index 000000000..406c87183 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Updated the task #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja b/taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja new file mode 100644 index 000000000..5b6406428 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("task", project.slug, snapshot.ref) %} +

New task created on {{ project }}

+

Hello {{ user }},
{{ changer }} has created a new task:

+

#{{ ref }} {{ subject }}

+ See task +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja b/taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja new file mode 100644 index 000000000..2eb7ddb9d --- /dev/null +++ b/taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("task", project.slug, snapshot.ref) %} +New task created on {{ project }} + +Hello {{ user }}, {{ changer }} has created a new task: + +#{{ ref }} {{ subject }} + +See task at {{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja b/taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja new file mode 100644 index 000000000..d7fd52f90 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Created the task #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja new file mode 100644 index 000000000..71cfbc183 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +

Task deleted on {{ project }}

+

Hello {{ user }},
{{ changer }} has deleted a task:

+

#{{ ref }} {{ subject }}

+

{{ signature }}

+ {% endtrans %} +{% endblock %} + diff --git a/taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja new file mode 100644 index 000000000..2f4ca8f73 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +Task deleted on {{ project }} + +Hello {{ user }}, {{ changer }} has deleted a task: + +#{{ ref }} {{ subject }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja b/taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja new file mode 100644 index 000000000..c60326d5e --- /dev/null +++ b/taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Deleted the task #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja new file mode 100644 index 000000000..c96093fee --- /dev/null +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-html.jinja" %} + +{% block head %} + {% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("userstory", project.slug, snapshot.ref) %} +

User Story updated on {{ project }}

+

Hello {{ user }},
{{ changer }} has updated a user story:

+

#{{ ref }} {{ subject }}

+ See user story + {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja new file mode 100644 index 000000000..5e85060ef --- /dev/null +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name,ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("userstory", project.slug, snapshot.ref) %} +User story updated on {{ project }} + +Hello {{ user }}, {{ changer }} has updated a user story: + +#{{ ref }} {{ subject }} + +See user story at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja new file mode 100644 index 000000000..8522fca39 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Updated the US #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja new file mode 100644 index 000000000..c3b47c2ad --- /dev/null +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("userstory", project.slug, snapshot.ref) %} +

New user story created on {{ project }}

+

Hello {{ user }},
{{ changer }} has created a new user story:

+

#{{ ref }} {{ subject }}

+ See user story +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja new file mode 100644 index 000000000..5cbce13d4 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject, url=resolve_front_url("userstory", project.slug, snapshot.ref) %} +New user story created on {{ project }} + +Hello {{ user }}, {{ changer }} has created a new user story: + +#{{ ref }} {{ subject }} + +See user story at {{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja new file mode 100644 index 000000000..20d97bdf3 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Created the US #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja new file mode 100644 index 000000000..a77a93998 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +

User Story deleted on {{ project }}

+

Hello {{ user }},
{{ changer }} has deleted a user story:

+

#{{ ref }} {{ subject }}

+

{{ signature }}

+ {% endtrans %} +{% endblock %} + diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja new file mode 100644 index 000000000..be0d82960 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, ref=snapshot.ref, subject=snapshot.subject %} +User Story deleted on {{ project }} + +Hello {{ user }}, {{ changer }} has deleted a user story + +#{{ ref }} {{ subject }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja new file mode 100644 index 000000000..0b5e65d5c --- /dev/null +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, ref=snapshot.ref, subject=snapshot.subject|safe %} +[{{ project }}] Deleted the US #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja new file mode 100644 index 000000000..c7ca9df35 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-html.jinja" %} + +{% block head %} + {% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, page=snapshot.slug, url=resolve_front_url("wiki", project.slug, snapshot.slug) %} +

Wiki Page updated on {{ project }}

+

Hello {{ user }},
{{ changer }} has updated a wiki page:

+

{{ page }}

+ See Wiki Page + {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja new file mode 100644 index 000000000..261fd5de6 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, page=snapshot.slug, url=resolve_front_url("wiki", project.slug, snapshot.slug) %} +Wiki Page updated on {{ project }} + +Hello {{ user }}, {{ changer }} has updated a wiki page: + +{{ page }} + +See wiki page at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja new file mode 100644 index 000000000..8cc33c422 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, page=snapshot.slug|safe %} +[{{ project }}] Updated the Wiki Page "{{ page }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja new file mode 100644 index 000000000..565aeab20 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, page=snapshot.slug, url=resolve_front_url("wiki", project.slug, snapshot.slug) %} +

New wiki page created on {{ project }}

+

Hello {{ user }},
{{ changer }} has created a new wiki page:

+

{{ page }}

+ See wiki page +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja new file mode 100644 index 000000000..db6799668 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja @@ -0,0 +1,20 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, page=snapshot.slug, url=resolve_front_url("wiki", project.slug, snapshot.slug) %} +New wiki page created page on {{ project }} + +Hello {{ user }}, {{ changer }} has created a new wiki page: + +{{ page }} + +See wiki page at {{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja new file mode 100644 index 000000000..daf233ded --- /dev/null +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, page=snapshot.slug|safe %} +[{{ project }}] Created the Wiki Page "{{ page }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja new file mode 100644 index 000000000..f42426ac0 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, page=snapshot.slug %} +

Wiki page deleted on {{ project }}

+

Hello {{ user }},
{{ changer }} has deleted a wiki page:

+

{{ page }}

+

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja new file mode 100644 index 000000000..2cb372c53 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), user=user.get_full_name(), changer=changer.get_full_name(), project=project.name, page=snapshot.slug %} +Wiki page deleted on {{ project }} + +Hello {{ user }}, {{ changer }} has deleted a wiki page: + +{{ page }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja new file mode 100644 index 000000000..015df40c1 --- /dev/null +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe, page=snapshot.slug|safe %} +[{{ project }}] Deleted the Wiki Page "{{ page }}" +{% endtrans %} diff --git a/taiga/projects/notifications/utils.py b/taiga/projects/notifications/utils.py new file mode 100644 index 000000000..b48eed778 --- /dev/null +++ b/taiga/projects/notifications/utils.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from .choices import NotifyLevel +from taiga.base.utils.text import strip_lines + +def attach_watchers_to_queryset(queryset, as_field="watchers"): + """Attach watching user ids to each object of the queryset. + + :param queryset: A Django queryset object. + :param as_field: Attach the watchers as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + + sql = ("""SELECT array(SELECT user_id + FROM notifications_watched + WHERE notifications_watched.content_type_id = {type_id} + AND notifications_watched.object_id = {tbl}.id)""") + sql = sql.format(type_id=type.id, tbl=model._meta.db_table) + qs = queryset.extra(select={as_field: sql}) + + return qs + + +def attach_is_watcher_to_queryset(queryset, user, as_field="is_watcher"): + """Attach is_watcher boolean to each object of the queryset. + + :param queryset: A Django queryset object. + :param user: A users.User object model + :param as_field: Attach the boolean as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + if user is None or user.is_anonymous: + sql = """SELECT false""" + else: + sql = ("""SELECT CASE WHEN (SELECT count(*) + FROM notifications_watched + WHERE notifications_watched.content_type_id = {type_id} + AND notifications_watched.object_id = {tbl}.id + AND notifications_watched.user_id = {user_id}) > 0 + THEN TRUE + ELSE FALSE + END""") + sql = sql.format(type_id=type.id, tbl=model._meta.db_table, user_id=user.id) + qs = queryset.extra(select={as_field: sql}) + return qs + + +def attach_total_watchers_to_queryset(queryset, as_field="total_watchers"): + """Attach total_watchers boolean to each object of the queryset. + + :param user: A users.User object model + :param queryset: A Django queryset object. + :param as_field: Attach the boolean as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + sql = ("""SELECT count(*) + FROM notifications_watched + WHERE notifications_watched.content_type_id = {type_id} + AND notifications_watched.object_id = {tbl}.id""") + sql = sql.format(type_id=type.id, tbl=model._meta.db_table) + qs = queryset.extra(select={as_field: sql}) + return qs diff --git a/taiga/projects/notifications/validators.py b/taiga/projects/notifications/validators.py new file mode 100644 index 000000000..f5c3698fb --- /dev/null +++ b/taiga/projects/notifications/validators.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base.exceptions import ValidationError + + +class WatchersValidator: + def validate_watchers(self, attrs, source): + users = attrs.get(source, []) + + # Try obtain a valid project + if self.object is None and "project" in attrs: + project = attrs["project"] + elif self.object: + project = self.object.project + else: + project = None + + # If project is empty in all conditions, continue + # without errors, because other validator should + # validate the empty project field. + if not project: + return attrs + + # Check if incoming watchers are contained + # in project members list + member_ids = project.members.values_list("id", flat=True) + existing_watcher_ids = project.get_watchers().values_list("id", flat=True) + result = set(users).difference(member_ids).difference(existing_watcher_ids) + if result: + raise ValidationError(_("Watchers contains invalid users")) + + return attrs diff --git a/taiga/projects/occ/__init__.py b/taiga/projects/occ/__init__.py new file mode 100644 index 000000000..754fa0176 --- /dev/null +++ b/taiga/projects/occ/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from .mixins import OCCResourceMixin +from .mixins import OCCModelMixin + +__all__ = ["OCCResourceMixin", "OCCModelMixin"] diff --git a/taiga/projects/occ/mixins.py b/taiga/projects/occ/mixins.py new file mode 100644 index 000000000..45e5f7c59 --- /dev/null +++ b/taiga/projects/occ/mixins.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from taiga.base import exceptions as exc +from taiga.base.utils import db +from taiga.projects.history.services import get_modified_fields + + +class OCCResourceMixin(object): + """ + Rest Framework resource mixin for resources that need to have concurrent + accesses and editions controlled. + """ + def _extract_param_version(self): + param_version = self.request.DATA.get('version', None) + try: + param_version = param_version and int(param_version) + except (ValueError, TypeError): + raise exc.WrongArguments({"version": _("The version must be an integer")}) + + return param_version + + def _validate_param_version(self, param_version, current_version): + if param_version is None: + return False + else: + if param_version < 0: + return False + if current_version is not None and param_version > current_version: + return False + + return True + + def _validate_and_update_version(self, obj): + current_version = None + if obj.id: + current_version = type(obj).objects.model.objects.get(id=obj.id).version + + # Extract param version + param_version = self._extract_param_version() + if not self._validate_param_version(param_version, current_version): + raise exc.WrongArguments({"version": _("The version parameter is not valid")}) + + if current_version != param_version: + diff_versions = current_version - param_version + + modifying_fields = set(self.request.DATA.keys()) + if "version" in modifying_fields: + modifying_fields.remove("version") + + modified_fields = set(get_modified_fields(obj, diff_versions)) + if "version" in modifying_fields: + modified_fields.remove("version") + + both_modified = modifying_fields & modified_fields + + if both_modified: + raise exc.WrongArguments({"version": _("The version doesn't match with the current one")}) + + obj.version = models.F('version') + 1 + + def pre_save(self, obj): + self._validate_and_update_version(obj) + super().pre_save(obj) + + def post_save(self, obj, created=False): + super().post_save(obj, created) + if not created: + obj.version = db.reload_attribute(obj, 'version') + + +class OCCModelMixin(models.Model): + """ + Generic model mixin that makes model compatible + with concurrency control system. + """ + version = models.IntegerField(null=False, blank=False, default=1, verbose_name=_("version")) + + class Meta: + abstract = True diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py new file mode 100644 index 000000000..72e906674 --- /dev/null +++ b/taiga/projects/permissions.py @@ -0,0 +1,253 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base import exceptions as exc + +from taiga.base.api.permissions import TaigaResourcePermission +from taiga.base.api.permissions import IsAuthenticated +from taiga.base.api.permissions import AllowAny +from taiga.base.api.permissions import DenyAll +from taiga.base.api.permissions import IsSuperUser +from taiga.base.api.permissions import IsObjectOwner +from taiga.base.api.permissions import PermissionComponent + +from taiga.permissions.permissions import HasProjectPerm +from taiga.permissions.permissions import IsProjectAdmin + +from . import models +from . import services + + +class CanLeaveProject(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if not obj or not request.user.is_authenticated: + return False + + try: + if not services.can_user_leave_project(request.user, obj): + raise exc.PermissionDenied(_("You can't leave the project if you are the owner or there are " + "no more admins")) + return True + except models.Membership.DoesNotExist: + return False + + +class ProjectPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + by_slug_perms = HasProjectPerm('view_project') + create_perms = IsAuthenticated() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + modules_perms = IsProjectAdmin() + list_perms = AllowAny() + change_logo_perms = IsProjectAdmin() + remove_logo_perms = IsProjectAdmin() + stats_perms = HasProjectPerm('view_project') + member_stats_perms = HasProjectPerm('view_project') + issues_stats_perms = HasProjectPerm('view_project') + regenerate_epics_csv_uuid_perms = IsProjectAdmin() + regenerate_userstories_csv_uuid_perms = IsProjectAdmin() + regenerate_issues_csv_uuid_perms = IsProjectAdmin() + regenerate_tasks_csv_uuid_perms = IsProjectAdmin() + delete_epics_csv_uuid_perms = IsProjectAdmin() + delete_userstories_csv_uuid_perms = IsProjectAdmin() + delete_issues_csv_uuid_perms = IsProjectAdmin() + delete_tasks_csv_uuid_perms = IsProjectAdmin() + tags_perms = HasProjectPerm('view_project') + tags_colors_perms = HasProjectPerm('view_project') + like_perms = IsAuthenticated() & HasProjectPerm('view_project') + unlike_perms = IsAuthenticated() & HasProjectPerm('view_project') + watch_perms = IsAuthenticated() & HasProjectPerm('view_project') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_project') + create_template_perms = IsSuperUser() + leave_perms = CanLeaveProject() + transfer_validate_token_perms = IsAuthenticated() & HasProjectPerm('view_project') + transfer_request_perms = IsProjectAdmin() + transfer_start_perms = IsObjectOwner() + transfer_reject_perms = IsAuthenticated() & HasProjectPerm('view_project') + transfer_accept_perms = IsAuthenticated() & HasProjectPerm('view_project') + create_tag_perms = IsProjectAdmin() + edit_tag_perms = IsProjectAdmin() + delete_tag_perms = IsProjectAdmin() + mix_tags_perms = IsProjectAdmin() + duplicate_perms = IsAuthenticated() & HasProjectPerm('view_project') + + +class ProjectFansPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + list_perms = HasProjectPerm('view_project') + + +class ProjectWatchersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + list_perms = HasProjectPerm('view_project') + + +class MembershipPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_create_perms = IsProjectAdmin() + resend_invitation_perms = IsProjectAdmin() + + +# Epics + +class EpicStatusPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +# User Stories + +class PointsPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class UserStoryStatusPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class SwimlanePermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class SwimlaneUserStoryStatusPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = DenyAll() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = DenyAll() + list_perms = AllowAny() + + +class UserStoryDueDatePermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +# Tasks + +class TaskStatusPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class TaskDueDatePermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + +# Issues + + +class SeverityPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class PriorityPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class IssueStatusPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class IssueTypePermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +class IssueDueDatePermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectAdmin() + + +# Project Templates + +class ProjectTemplatePermission(TaigaResourcePermission): + retrieve_perms = AllowAny() + create_perms = IsSuperUser() + update_perms = IsSuperUser() + partial_update_perms = IsSuperUser() + destroy_perms = IsSuperUser() + list_perms = AllowAny() diff --git a/taiga/projects/references/__init__.py b/taiga/projects/references/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/references/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/references/api.py b/taiga/projects/references/api.py new file mode 100644 index 000000000..4e1f977c5 --- /dev/null +++ b/taiga/projects/references/api.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps + +from taiga.base import exceptions as exc +from taiga.base import response +from taiga.base.api import viewsets +from taiga.base.api.utils import get_object_or_error +from taiga.permissions.services import user_has_perm + +from .validators import ResolverValidator +from . import permissions + + +class ResolverViewSet(viewsets.ViewSet): + permission_classes = (permissions.ResolverPermission,) + + def list(self, request, **kwargs): + validator = ResolverValidator(data=request.QUERY_PARAMS) + if not validator.is_valid(): + raise exc.BadRequest(validator.errors) + + data = validator.data + + project_model = apps.get_model("projects", "Project") + project = get_object_or_error(project_model, request.user, slug=data["project"]) + + self.check_permissions(request, "list", project) + + result = {"project": project.pk} + + if data["epic"] and user_has_perm(request.user, "view_epics", project): + result["epic"] = get_object_or_error(project.epics.all(), request.user, + ref=data["epic"]).pk + if data["us"] and user_has_perm(request.user, "view_us", project): + result["us"] = get_object_or_error(project.user_stories.all(), request.user, + ref=data["us"]).pk + if data["task"] and user_has_perm(request.user, "view_tasks", project): + result["task"] = get_object_or_error(project.tasks.all(), request.user, + ref=data["task"]).pk + if data["issue"] and user_has_perm(request.user, "view_issues", project): + result["issue"] = get_object_or_error(project.issues.all(), request.user, + ref=data["issue"]).pk + if data["milestone"] and user_has_perm(request.user, "view_milestones", project): + result["milestone"] = get_object_or_error(project.milestones.all(), request.user, + slug=data["milestone"]).pk + if data["wikipage"] and user_has_perm(request.user, "view_wiki_pages", project): + result["wikipage"] = get_object_or_error(project.wiki_pages.all(), request.user, + slug=data["wikipage"]).pk + + if data["ref"]: + ref_found = False # No need to continue once one ref is found + try: + value = int(data["ref"]) + + if user_has_perm(request.user, "view_epics", project): + epic = project.epics.filter(ref=value).first() + if epic: + result["epic"] = epic.pk + ref_found = True + if ref_found is False and user_has_perm(request.user, "view_us", project): + us = project.user_stories.filter(ref=value).first() + if us: + result["us"] = us.pk + ref_found = True + if ref_found is False and user_has_perm(request.user, "view_tasks", project): + task = project.tasks.filter(ref=value).first() + if task: + result["task"] = task.pk + ref_found = True + if ref_found is False and user_has_perm(request.user, "view_issues", project): + issue = project.issues.filter(ref=value).first() + if issue: + result["issue"] = issue.pk + except: + value = data["ref"] + + if user_has_perm(request.user, "view_wiki_pages", project): + wiki_page = project.wiki_pages.filter(slug=value).first() + if wiki_page: + result["wikipage"] = wiki_page.pk + + return response.Ok(result) diff --git a/taiga/projects/references/migrations/0001_initial.py b/taiga/projects/references/migrations/0001_initial.py new file mode 100644 index 000000000..4ec8bc48d --- /dev/null +++ b/taiga/projects/references/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0002_auto_20140903_0920'), + ('contenttypes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Reference', + fields=[ + ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), + ('object_id', models.PositiveIntegerField()), + ('ref', models.BigIntegerField()), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('content_type', models.ForeignKey(related_name='+', to='contenttypes.ContentType', on_delete=models.CASCADE)), + ('project', models.ForeignKey(related_name='references', to='projects.Project', on_delete=models.CASCADE)), + ], + options={ + 'ordering': ['created_at'], + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='reference', + unique_together=set([('project', 'ref')]), + ), + ] diff --git a/taiga/projects/references/migrations/__init__.py b/taiga/projects/references/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/references/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/references/models.py b/taiga/projects/references/models.py new file mode 100644 index 000000000..a7cdf9d87 --- /dev/null +++ b/taiga/projects/references/models.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.utils import timezone +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey + +from taiga.projects.models import Project +from taiga.projects.epics.models import Epic +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task +from taiga.projects.issues.models import Issue + +from . import sequences as seq + + +class Reference(models.Model): + content_type = models.ForeignKey(ContentType, related_name="+", on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + ref = models.BigIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + created_at = models.DateTimeField(default=timezone.now) + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="references", + on_delete=models.CASCADE, + ) + + class Meta: + ordering = ["created_at"] + unique_together = ["project", "ref"] + + def __str__(self): + return "Reference {}".format(self.object_id) + + +def make_sequence_name(project) -> str: + return "references_project{0}".format(project.pk) + + +def make_unique_reference_id(project, *, create=False): + seqname = make_sequence_name(project) + if create and not seq.exists(seqname): + seq.create(seqname) + return seq.next_value(seqname) + + +def make_reference(instance, project, create=False): + refval = make_unique_reference_id(project, create=create) + ct = ContentType.objects.get_for_model(instance.__class__) + refinstance = Reference.objects.create(content_type=ct, + object_id=instance.pk, + ref=refval, + project=project) + return refval, refinstance + + +def recalc_reference_counter(project): + seqname = make_sequence_name(project) + max_ref_us = project.user_stories.all().aggregate(max=models.Max('ref')) + max_ref_task = project.tasks.all().aggregate(max=models.Max('ref')) + max_ref_issue = project.issues.all().aggregate(max=models.Max('ref')) + max_references = list(filter(lambda x: x is not None, [max_ref_us['max'], max_ref_task['max'], max_ref_issue['max']])) + + max_value = 0 + if len(max_references) > 0: + max_value = max(max_references) + seq.set_max(seqname, max_value) + + +def create_sequence(sender, instance, created, **kwargs): + if not created: + return + + seqname = make_sequence_name(instance) + if not seq.exists(seqname): + seq.create(seqname) + + +def delete_sequence(sender, instance, **kwargs): + seqname = make_sequence_name(instance) + if seq.exists(seqname): + seq.delete(seqname) + + +def store_previous_project(sender, instance, **kwargs): + try: + prev_instance = sender.objects.get(pk=instance.pk) + instance.prev_project = prev_instance.project + except sender.DoesNotExist: + instance.prev_project = None + + +def attach_sequence(sender, instance, created, **kwargs): + if not instance._importing: + if created or instance.prev_project != instance.project: + # Create a reference object. This operation should be + # used in transaction context, otherwise it can + # create a lot of phantom reference objects. + refval, _ = make_reference(instance, instance.project) + + # Additionally, attach sequence number to instance as ref + instance.ref = refval + instance.save(update_fields=['ref']) + + +# Project +models.signals.post_save.connect(create_sequence, sender=Project, dispatch_uid="refproj") +models.signals.post_delete.connect(delete_sequence, sender=Project, dispatch_uid="refprojdel") + +# Epic +models.signals.pre_save.connect(store_previous_project, sender=Epic, dispatch_uid="refepic") +models.signals.post_save.connect(attach_sequence, sender=Epic, dispatch_uid="refepic") + +# User Story +models.signals.pre_save.connect(store_previous_project, sender=UserStory, dispatch_uid="refus") +models.signals.post_save.connect(attach_sequence, sender=UserStory, dispatch_uid="refus") + +# Task +models.signals.pre_save.connect(store_previous_project, sender=Task, dispatch_uid="reftask") +models.signals.post_save.connect(attach_sequence, sender=Task, dispatch_uid="reftask") + +# Issue +models.signals.pre_save.connect(store_previous_project, sender=Issue, dispatch_uid="refissue") +models.signals.post_save.connect(attach_sequence, sender=Issue, dispatch_uid="refissue") diff --git a/taiga/projects/references/permissions.py b/taiga/projects/references/permissions.py new file mode 100644 index 000000000..aeabbb0aa --- /dev/null +++ b/taiga/projects/references/permissions.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, + IsProjectAdmin, AllowAny) + + +class ResolverPermission(TaigaResourcePermission): + list_perms = HasProjectPerm('view_project') diff --git a/taiga/projects/references/sequences.py b/taiga/projects/references/sequences.py new file mode 100644 index 000000000..4c839658c --- /dev/null +++ b/taiga/projects/references/sequences.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from contextlib import closing +from django.db import connection +from django.db import ProgrammingError + + +def create(seqname:str, start=1) -> None: + sql = "CREATE SEQUENCE {0} START %s".format(seqname) + + with closing(connection.cursor()) as cursor: + cursor.execute(sql, [start]) + + +def exists(seqname:str) -> bool: + sql = """ + SELECT EXISTS( + SELECT relname FROM pg_class + WHERE relkind = 'S' AND relname = %s); + """ + + with closing(connection.cursor()) as cursor: + cursor.execute(sql, [seqname]) + result = cursor.fetchone() + return result[0] + + +def alter(seqname:str, value:int) -> None: + sql = "SELECT setval(%s, %s);" + with closing(connection.cursor()) as cursor: + cursor.execute(sql, [seqname, value]) + + +def delete(seqname:str) -> None: + sql = "DROP SEQUENCE {0};".format(seqname) + with closing(connection.cursor()) as cursor: + cursor.execute(sql) + +def next_value(seqname): + sql = "SELECT nextval(%s);" + with closing(connection.cursor()) as cursor: + cursor.execute(sql, [seqname]) + result = cursor.fetchone() + return result[0] + +def set_max(seqname, new_value): + sql = "SELECT setval(%s, GREATEST(nextval(%s), %s));" + with closing(connection.cursor()) as cursor: + cursor.execute(sql, [seqname, seqname, new_value]) + result = cursor.fetchone() + return result[0] diff --git a/taiga/projects/references/services.py b/taiga/projects/references/services.py new file mode 100644 index 000000000..82e36de26 --- /dev/null +++ b/taiga/projects/references/services.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps + + +def get_instance_by_ref(project_id, obj_ref): + model_cls = apps.get_model("references", "Reference") + try: + instance = model_cls.objects.get(project_id=project_id, ref=obj_ref) + except model_cls.DoesNotExist: + instance = None + + return instance diff --git a/taiga/projects/references/validators.py b/taiga/projects/references/validators.py new file mode 100644 index 000000000..5f1a622b0 --- /dev/null +++ b/taiga/projects/references/validators.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.exceptions import ValidationError + + +class ResolverValidator(validators.Validator): + project = serializers.CharField(max_length=512, required=True) + milestone = serializers.CharField(max_length=512, required=False) + epic = serializers.IntegerField(required=False) + us = serializers.IntegerField(required=False) + task = serializers.IntegerField(required=False) + issue = serializers.IntegerField(required=False) + wikipage = serializers.CharField(max_length=512, required=False) + ref = serializers.CharField(max_length=512, required=False) + + def validate(self, attrs): + if "ref" in attrs: + if "epic" in attrs: + raise ValidationError("'epic' param is incompatible with 'ref' in the same request") + if "us" in attrs: + raise ValidationError("'us' param is incompatible with 'ref' in the same request") + if "task" in attrs: + raise ValidationError("'task' param is incompatible with 'ref' in the same request") + if "issue" in attrs: + raise ValidationError("'issue' param is incompatible with 'ref' in the same request") + if "wikipage" in attrs: + raise ValidationError("'wikipage' param is incompatible with 'ref' in the same request") + + return attrs diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py new file mode 100644 index 000000000..f485325c6 --- /dev/null +++ b/taiga/projects/serializers.py @@ -0,0 +1,692 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField, I18NField + +from taiga.permissions import services as permissions_services +from taiga.users.services import get_photo_url, get_user_photo_url +from taiga.users.gravatar import get_gravatar_id, get_user_gravatar_id +from taiga.users.serializers import UserBasicInfoSerializer + +from taiga.permissions.services import calculate_permissions +from taiga.permissions.services import is_project_admin, is_project_owner + +from . import services +from .notifications.choices import NotifyLevel + + +###################################################### +# Custom values for selectors +###################################################### + +class BaseDueDateSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + order = Field() + by_default = Field() + days_to_due = Field() + color = Field() + project = Field(attr="project_id") + + +class EpicStatusSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + project = Field(attr="project_id") + + +class UserStoryStatusSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + slug = Field() + order = Field() + is_closed = Field() + is_archived = Field() + color = Field() + wip_limit = Field() + project = Field(attr="project_id") + + +class PointsSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + order = Field() + value = Field() + project = Field(attr="project_id") + + +class _SwimlaneStatusSerializer(serializers.LightSerializer): + id = MethodField() + name = MethodField() + slug = MethodField() + order = MethodField() + is_closed = MethodField() + is_archived = MethodField() + color = MethodField() + wip_limit = Field() + swimlane_userstory_status_id = Field(attr="id") + + def get_id(self, obj): return obj.status.id + def get_name(self, obj): return _(obj.status.name) + def get_slug(self, obj): return obj.status.slug + def get_order(self, obj): return obj.status.order + def get_is_closed(self, obj): return obj.status.is_closed + def get_is_archived(self, obj): return obj.status.is_archived + def get_color(self, obj): return obj.status.color + + +class SwimlaneSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + order = Field() + project = Field(attr="project_id") + statuses = _SwimlaneStatusSerializer(many=True, attr="statuses.all", call=True) + + +class SwimlaneUserStoryStatusSerializer(serializers.LightSerializer): + id = Field() + status = Field(attr="status_id") + swimlane = Field(attr="swimlane_id") + wip_limit = Field() + + +class UserStoryDueDateSerializer(BaseDueDateSerializer): + pass + + +class TaskStatusSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + project = Field(attr="project_id") + + +class TaskDueDateSerializer(BaseDueDateSerializer): + pass + + +class SeveritySerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + order = Field() + color = Field() + project = Field(attr="project_id") + + +class PrioritySerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + order = Field() + color = Field() + project = Field(attr="project_id") + + +class IssueStatusSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + project = Field(attr="project_id") + + +class IssueTypeSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + order = Field() + color = Field() + project = Field(attr="project_id") + + +class IssueDueDateSerializer(BaseDueDateSerializer): + pass + + +###################################################### +# Members +###################################################### + +class MembershipDictSerializer(serializers.LightDictSerializer): + role = Field() + role_name = Field() + full_name = Field() + full_name_display = MethodField() + is_active = Field() + id = Field() + color = Field() + username = Field() + photo = MethodField() + gravatar_id = MethodField() + + def get_full_name_display(self, obj): + return obj["full_name"] or obj["username"] or obj["email"] + + def get_photo(self, obj): + return get_photo_url(obj['photo']) + + def get_gravatar_id(self, obj): + return get_gravatar_id(obj['email']) + + +class MembershipSerializer(serializers.LightSerializer): + id = Field() + user = Field(attr="user_id") + project = Field(attr="project_id") + role = Field(attr="role_id") + is_admin = Field() + created_at = Field() + invited_by = Field(attr="invited_by_id") + invitation_extra_text = Field() + user_order = Field() + role_name = MethodField() + full_name = MethodField() + is_user_active = MethodField() + color = MethodField() + photo = MethodField() + gravatar_id = MethodField() + project_name = MethodField() + project_slug = MethodField() + invited_by = UserBasicInfoSerializer() + is_owner = MethodField() + + def get_role_name(self, obj): + return obj.role.name if obj.role else None + + def get_full_name(self, obj): + return obj.user.get_full_name() if obj.user else None + + def get_is_user_active(self, obj): + return obj.user.is_active if obj.user else False + + def get_color(self, obj): + return obj.user.color if obj.user else None + + def get_photo(self, obj): + return get_user_photo_url(obj.user) + + def get_gravatar_id(self, obj): + return get_user_gravatar_id(obj.user) + + def get_project_name(self, obj): + return obj.project.name if obj and obj.project else "" + + def get_project_slug(self, obj): + return obj.project.slug if obj and obj.project else "" + + def get_is_owner(self, obj): + return (obj and obj.user_id and obj.project_id and obj.project.owner_id and + obj.user_id == obj.project.owner_id) + + +class MembershipAdminSerializer(MembershipSerializer): + email = Field() + user_email = MethodField() + + def get_user_email(self, obj): + return obj.user.email if obj.user else None + + # IMPORTANT: Maintain the MembershipSerializer Meta up to date + # with this info (excluding there user_email and email) + + +###################################################### +# Projects +###################################################### + +class ProjectSerializer(serializers.LightSerializer): + id = Field() + name = Field() + slug = Field() + description = Field() + created_date = Field() + modified_date = Field() + owner = MethodField() + members = MethodField() + total_milestones = Field() + total_story_points = Field() + is_contact_activated = Field() + is_epics_activated = Field() + is_backlog_activated = Field() + is_kanban_activated = Field() + is_wiki_activated = Field() + is_issues_activated = Field() + videoconferences = Field() + videoconferences_extra_data = Field() + creation_template = Field(attr="creation_template_id") + is_private = Field() + anon_permissions = Field() + public_permissions = Field() + is_featured = Field() + is_looking_for_people = Field() + looking_for_people_note = Field() + blocked_code = Field() + totals_updated_datetime = Field() + total_fans = Field() + total_fans_last_week = Field() + total_fans_last_month = Field() + total_fans_last_year = Field() + total_activity = Field() + total_activity_last_week = Field() + total_activity_last_month = Field() + total_activity_last_year = Field() + + tags = Field() + tags_colors = MethodField() + + default_epic_status = Field(attr="default_epic_status_id") + default_points = Field(attr="default_points_id") + default_us_status = Field(attr="default_us_status_id") + default_task_status = Field(attr="default_task_status_id") + default_priority = Field(attr="default_priority_id") + default_severity = Field(attr="default_severity_id") + default_issue_status = Field(attr="default_issue_status_id") + default_issue_type = Field(attr="default_issue_type_id") + default_swimlane = Field(attr="default_swimlane_id") + + my_permissions = MethodField() + + i_am_owner = MethodField() + i_am_admin = MethodField() + i_am_member = MethodField() + + notify_level = MethodField() + total_closed_milestones = MethodField() + + is_watcher = MethodField() + total_watchers = MethodField() + + logo_small_url = MethodField() + logo_big_url = MethodField() + + 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: + return [] + + return [m.get("id") for m in obj.members_attr if m["id"] is not None] + + def get_i_am_member(self, obj): + assert hasattr(obj, "members_attr"), "instance must have a members_attr attribute" + if obj.members_attr is None: + return False + + if "request" in self.context: + user = self.context["request"].user + user_ids = [m.get("id") for m in obj.members_attr if m["id"] is not None] + if not user.is_anonymous and user.id in user_ids: + return True + + return False + + def get_tags_colors(self, obj): + return dict(obj.tags_colors) + + def get_my_permissions(self, obj): + if "request" in self.context: + user = self.context["request"].user + return calculate_permissions(is_authenticated=user.is_authenticated, + is_superuser=user.is_superuser, + is_member=self.get_i_am_member(obj), + is_admin=self.get_i_am_admin(obj), + role_permissions=obj.my_role_permissions_attr, + anon_permissions=obj.anon_permissions, + public_permissions=obj.public_permissions) + return [] + + def get_owner(self, obj): + return UserBasicInfoSerializer(obj.owner).data + + def get_i_am_owner(self, obj): + if "request" in self.context: + return is_project_owner(self.context["request"].user, obj) + return False + + def get_i_am_admin(self, obj): + if "request" in self.context: + return is_project_admin(self.context["request"].user, obj) + return False + + def get_total_closed_milestones(self, obj): + assert hasattr(obj, "closed_milestones_attr"), "instance must have a closed_milestones_attr attribute" + return obj.closed_milestones_attr + + def get_is_watcher(self, obj): + assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" + np = self.get_notify_level(obj) + return np is not None and np != NotifyLevel.none + + def get_total_watchers(self, obj): + assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" + if obj.notify_policies_attr is None: + return 0 + + valid_notify_policies = [np for np in obj.notify_policies_attr if np["notify_level"] != NotifyLevel.none] + return len(valid_notify_policies) + + def get_notify_level(self, obj): + assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" + if obj.notify_policies_attr is None: + return None + + if "request" in self.context: + user = self.context["request"].user + for np in obj.notify_policies_attr: + if np["user_id"] == user.id: + return np["notify_level"] + + return None + + def get_logo_small_url(self, obj): + return services.get_logo_small_thumbnail_url(obj) + + 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") + swimlanes = Field(attr="swimlanes_attr") + us_statuses = Field(attr="userstory_statuses_attr") + us_duedates = Field(attr="userstory_duedates_attr") + points = Field(attr="points_attr") + task_statuses = Field(attr="task_statuses_attr") + task_duedates = Field(attr="task_duedates_attr") + issue_statuses = Field(attr="issue_statuses_attr") + issue_types = Field(attr="issue_types_attr") + issue_duedates = Field(attr="issue_duedates_attr") + priorities = Field(attr="priorities_attr") + severities = Field(attr="severities_attr") + epic_custom_attributes = Field(attr="epic_custom_attributes_attr") + userstory_custom_attributes = Field(attr="userstory_custom_attributes_attr") + task_custom_attributes = Field(attr="task_custom_attributes_attr") + issue_custom_attributes = Field(attr="issue_custom_attributes_attr") + roles = Field(attr="roles_attr") + members = MethodField() + total_memberships = MethodField() + is_out_of_owner_limits = MethodField() + + # Admin fields + is_private_extra_info = MethodField() + max_memberships = MethodField() + epics_csv_uuid = Field() + userstories_csv_uuid = Field() + tasks_csv_uuid = Field() + issues_csv_uuid = Field() + transfer_token = Field() + milestones = MethodField() + + def get_milestones(self, obj): + assert hasattr(obj, "milestones_attr"), "instance must have a milestones_attr attribute" + if obj.milestones_attr is None: + return [] + + return obj.milestones_attr + + def to_value(self, instance): + # Name attributes must be translated + for attr in ["epic_statuses_attr", "userstory_statuses_attr", + "userstory_duedates_attr", "points_attr", + "task_statuses_attr", "task_duedates_attr", + "issue_statuses_attr", "issue_types_attr", + "issue_duedates_attr", "priorities_attr", + "severities_attr", "epic_custom_attributes_attr", + "userstory_custom_attributes_attr", + "task_custom_attributes_attr", + "issue_custom_attributes_attr", "roles_attr", + "swimlanes_attr"]: + + assert hasattr(instance, attr), "instance must have a {} attribute".format(attr) + val = getattr(instance, attr) + if val is None: + continue + + for elem in val: + elem["name"] = _(elem["name"]) + + ret = super().to_value(instance) + + admin_fields = [ + "epics_csv_uuid", "userstories_csv_uuid", "tasks_csv_uuid", "issues_csv_uuid", + "is_private_extra_info", "max_memberships", "transfer_token", + ] + + is_admin_user = False + if "request" in self.context: + user = self.context["request"].user + is_admin_user = permissions_services.is_project_admin(user, instance) + + if not is_admin_user: + for admin_field in admin_fields: + del(ret[admin_field]) + + return ret + + def get_members(self, obj): + assert hasattr(obj, "members_attr"), "instance must have a members_attr attribute" + if obj.members_attr is None: + return [] + + return MembershipDictSerializer([m for m in obj.members_attr if m['id'] is not None], many=True).data + + def get_total_memberships(self, obj): + if obj.members_attr is None: + return 0 + + return len(obj.members_attr) + + def get_is_out_of_owner_limits(self, obj): + return services.check_if_project_is_out_of_owner_limits(obj) + + def get_is_private_extra_info(self, obj): + return services.check_if_project_privacy_can_be_changed(obj) + + def get_max_memberships(self, obj): + return services.get_max_memberships_for_project(obj) + + +class ProjectLightSerializer(serializers.LightSerializer): + id = Field() + name = Field() + slug = Field() + description = Field() + created_date = Field() + modified_date = Field() + owner = MethodField() + members = MethodField() + total_milestones = Field() + total_story_points = Field() + is_contact_activated = Field() + is_epics_activated = Field() + is_backlog_activated = Field() + is_kanban_activated = Field() + is_wiki_activated = Field() + is_issues_activated = Field() + videoconferences = Field() + videoconferences_extra_data = Field() + creation_template = Field(attr="creation_template_id") + is_private = Field() + anon_permissions = Field() + public_permissions = Field() + is_featured = Field() + is_looking_for_people = Field() + looking_for_people_note = Field() + blocked_code = Field() + totals_updated_datetime = Field() + total_fans = Field() + total_fans_last_week = Field() + total_fans_last_month = Field() + total_fans_last_year = Field() + total_activity = Field() + total_activity_last_week = Field() + total_activity_last_month = Field() + total_activity_last_year = Field() + + tags = Field() + tags_colors = MethodField() + + default_epic_status = Field(attr="default_epic_status_id") + default_points = Field(attr="default_points_id") + default_us_status = Field(attr="default_us_status_id") + default_task_status = Field(attr="default_task_status_id") + default_priority = Field(attr="default_priority_id") + default_severity = Field(attr="default_severity_id") + default_issue_status = Field(attr="default_issue_status_id") + default_issue_type = Field(attr="default_issue_type_id") + default_swimlane = Field(attr="default_swimlane_id") + + my_permissions = MethodField() + + i_am_owner = MethodField() + i_am_admin = MethodField() + i_am_member = MethodField() + + is_watcher = MethodField() + total_watchers = MethodField() + + logo_small_url = MethodField() + + 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: + return [] + + return [m.get("id") for m in obj.members_attr if m["id"] is not None] + + def get_i_am_member(self, obj): + assert hasattr(obj, "members_attr"), "instance must have a members_attr attribute" + if obj.members_attr is None: + return False + + if "request" in self.context: + user = self.context["request"].user + user_ids = [m.get("id") for m in obj.members_attr if m["id"] is not None] + if not user.is_anonymous and user.id in user_ids: + return True + + return False + + def get_tags_colors(self, obj): + return dict(obj.tags_colors) + + def get_my_permissions(self, obj): + if "request" in self.context: + user = self.context["request"].user + return calculate_permissions(is_authenticated=user.is_authenticated, + is_superuser=user.is_superuser, + is_member=self.get_i_am_member(obj), + is_admin=self.get_i_am_admin(obj), + role_permissions=obj.my_role_permissions_attr, + anon_permissions=obj.anon_permissions, + public_permissions=obj.public_permissions) + return [] + + def get_owner(self, obj): + return UserBasicInfoSerializer(obj.owner).data + + def get_i_am_owner(self, obj): + if "request" in self.context: + return is_project_owner(self.context["request"].user, obj) + return False + + def get_i_am_admin(self, obj): + if "request" in self.context: + return is_project_admin(self.context["request"].user, obj) + return False + + def get_is_watcher(self, obj): + assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" + np = self.get_notify_level(obj) + return np is not None and np != NotifyLevel.none + + def get_total_watchers(self, obj): + assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" + if obj.notify_policies_attr is None: + return 0 + + valid_notify_policies = [np for np in obj.notify_policies_attr if np["notify_level"] != NotifyLevel.none] + return len(valid_notify_policies) + + def get_notify_level(self, obj): + assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" + if obj.notify_policies_attr is None: + return None + + if "request" in self.context: + user = self.context["request"].user + for np in obj.notify_policies_attr: + if np["user_id"] == user.id: + return np["notify_level"] + + return None + + def get_logo_small_url(self, obj): + return services.get_logo_small_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 + + +###################################################### +# Project Templates +###################################################### +class ProjectTemplateSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + slug = Field() + description = I18NField() + order = Field() + created_date = Field() + modified_date = Field() + default_owner_role = Field() + is_contact_activated = Field() + is_epics_activated = Field() + is_backlog_activated = Field() + is_kanban_activated = Field() + is_wiki_activated = Field() + is_issues_activated = Field() + videoconferences = Field() + videoconferences_extra_data = Field() + default_options = Field() + epic_statuses = Field() + us_statuses = Field() + points = Field() + task_statuses = Field() + issue_statuses = Field() + issue_types = Field() + priorities = Field() + severities = Field() + roles = Field() diff --git a/taiga/projects/services/__init__.py b/taiga/projects/services/__init__.py new file mode 100644 index 000000000..cc97e4961 --- /dev/null +++ b/taiga/projects/services/__init__.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# This makes all code that import services works and +# is not the baddest practice ;) + +# flake8: noqa + + +from .bulk_update_order import apply_order_updates +from .bulk_update_order import bulk_update_severity_order +from .bulk_update_order import bulk_update_priority_order +from .bulk_update_order import bulk_update_issue_type_order +from .bulk_update_order import bulk_update_issue_status_order +from .bulk_update_order import bulk_update_task_status_order +from .bulk_update_order import bulk_update_points_order +from .bulk_update_order import bulk_update_userstory_status_order +from .bulk_update_order import bulk_update_epic_status_order +from .bulk_update_order import bulk_update_swimlane_order +from .bulk_update_order import update_projects_order_in_bulk + +from .filters import get_all_tags + +from .invitations import send_invitation +from .invitations import find_invited_user + +from .logo import get_logo_small_thumbnail_url +from .logo import get_logo_big_thumbnail_url + +from .members import create_members_in_bulk +from .members import get_members_from_bulk +from .members import remove_user_from_project, project_has_valid_admins, can_user_leave_project +from .members import get_max_memberships_for_project, get_total_project_memberships +from .members import check_if_new_member_can_be_created +from .members import check_if_new_members_can_be_created + +from .modules_config import get_modules_config + +from .projects import check_if_project_privacy_can_be_changed +from .projects import check_if_project_can_be_created_or_updated +from .projects import check_if_project_can_be_transfered +from .projects import check_if_project_can_be_duplicate +from .projects import check_if_project_is_out_of_owner_limits +from .projects import orphan_project +from .projects import delete_project +from .projects import delete_projects +from .projects import duplicate_project + +from .stats import get_stats_for_project_issues +from .stats import get_stats_for_project +from .stats import get_member_stats_for_project + +from .transfer import request_project_transfer, start_project_transfer +from .transfer import accept_project_transfer, reject_project_transfer diff --git a/taiga/projects/services/bulk_update_order.py b/taiga/projects/services/bulk_update_order.py new file mode 100644 index 000000000..a7626a521 --- /dev/null +++ b/taiga/projects/services/bulk_update_order.py @@ -0,0 +1,298 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from contextlib import suppress +from operator import itemgetter + +from django.db import transaction, connection +from django.core.exceptions import ObjectDoesNotExist +from psycopg2.extras import execute_values + +from taiga.events import events +from taiga.projects import models + + +def apply_order_updates(base_orders: dict, new_orders: dict, *, remove_equal_original=False): + """ + `base_orders` must be a dict containing all the elements that can be affected by + order modifications. + `new_orders` must be a dict containing the basic order modifications to apply. + + The result will a base_orders with the specified order changes in new_orders + and the extra calculated ones applied. + Extra order updates can be needed when moving elements to intermediate positions. + The elements where no order update is needed will be removed. + """ + updated_order_ids = set() + original_orders = {k: v for k, v in base_orders.items()} + + # Remove the elements from new_orders non existint in base_orders + invalid_keys = new_orders.keys() - base_orders.keys() + [new_orders.pop(id, None) for id in invalid_keys] + + # We will apply the multiple order changes by the new position order + sorted_new_orders = [(k, v) for k, v in new_orders.items()] + sorted_new_orders = sorted(sorted_new_orders, key=lambda e: e[1]) + + for new_order in sorted_new_orders: + old_order = base_orders[new_order[0]] + new_order = new_order[1] + for id, order in base_orders.items(): + # When moving forward only the elements contained in the range new_order - old_order + # positions need to be updated + moving_backward = new_order <= old_order and order >= new_order and order < old_order + # When moving backward all the elements from the new_order position need to bee updated + moving_forward = new_order >= old_order and order >= new_order + if moving_backward or moving_forward: + base_orders[id] += 1 + updated_order_ids.add(id) + + # Overwriting the orders specified + for id, order in new_orders.items(): + if base_orders[id] != order: + base_orders[id] = order + updated_order_ids.add(id) + + # Remove not modified elements + removing_keys = [id for id in base_orders if id not in updated_order_ids] + [base_orders.pop(id, None) for id in removing_keys] + + # Remove the elements that remains the same + if remove_equal_original: + common_keys = base_orders.keys() & original_orders.keys() + [base_orders.pop(id, None) for id in common_keys if original_orders[id] == base_orders[id]] + + +def update_projects_order_in_bulk(bulk_data: list, field: str, user): + """ + Update the order of user projects in the user membership. + `bulk_data` should be a list of dicts with the following format: + + [{'project_id': , 'order': }, ...] + """ + memberships_orders = {m.id: getattr(m, field) for m in user.memberships.all()} + new_memberships_orders = {} + + for membership_data in bulk_data: + project_id = membership_data["project_id"] + with suppress(ObjectDoesNotExist): + membership = user.memberships.get(project_id=project_id) + new_memberships_orders[membership.id] = membership_data["order"] + + apply_order_updates(memberships_orders, new_memberships_orders) + + from taiga.base.utils import db + db.update_attr_in_bulk_for_ids(memberships_orders, field, model=models.Membership) + + +@transaction.atomic +def bulk_update_epic_status_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update projects_epicstatus set "order" = $1 + where projects_epicstatus.id = $2 and + projects_epicstatus.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_userstory_status_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update projects_userstorystatus set "order" = $1 + where projects_userstorystatus.id = $2 and + projects_userstorystatus.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_points_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update projects_points set "order" = $1 + where projects_points.id = $2 and + projects_points.project_id = $3; + """ + + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_task_status_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update projects_taskstatus set "order" = $1 + where projects_taskstatus.id = $2 and + projects_taskstatus.project_id = $3; + """ + + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_issue_status_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update projects_issuestatus set "order" = $1 + where projects_issuestatus.id = $2 and + projects_issuestatus.project_id = $3; + """ + + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_issue_type_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update projects_issuetype set "order" = $1 + where projects_issuetype.id = $2 and + projects_issuetype.project_id = $3; + """ + + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_priority_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update projects_priority set "order" = $1 + where projects_priority.id = $2 and + projects_priority.project_id = $3; + """ + + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_severity_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update projects_severity set "order" = $1 + where projects_severity.id = $2 and + projects_severity.project_id = $3; + """ + + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_swimlane_order(project, user, data): + with connection.cursor() as curs: + execute_values(curs, + """ + UPDATE projects_swimlane + SET "order" = tmp.new_order + FROM (VALUES %s) AS tmp (id, new_order) + WHERE tmp.id = projects_swimlane.id""", + data) + + # Send event related to swimlane changes + swimlane_ids = tuple(map(itemgetter(0), data)) + events.emit_event_for_ids(ids=swimlane_ids, + content_type="projects.swimlane", + projectid=project.pk) + +@transaction.atomic +def update_order_and_swimlane(swimlane_to_be_deleted, move_to_swimlane): + + # first of all, there will be the user stories without swimlane + uss_without_swimlane = swimlane_to_be_deleted.project.user_stories \ + .filter(swimlane=None).order_by('kanban_order', 'id') + ordered_uss_ids = list(uss_without_swimlane.values_list('id', flat=True)) + ordered_swimlane_ids = [None] * len(ordered_uss_ids) + + # get the uss paired with its swimlane + # except the uss in the swimlane to be deleted, which will go to the destination swimlane + ordered_swimlanes = {} + for s in swimlane_to_be_deleted.project.swimlanes.order_by('order'): + s_id = s.id + if s_id == swimlane_to_be_deleted.id: + s_id = move_to_swimlane.id + + ordered_swimlanes[s.id] = { + 'ordered_uss': list(s.user_stories.order_by('kanban_order', 'id').values_list('id', flat=True)), + 'swimlane_id': [s_id] * s.user_stories.count() + } + + # put the uss in the swimlane to be deleted after the uss in the destination swimlane + ordered_swimlanes[move_to_swimlane.id]['ordered_uss'].extend( + ordered_swimlanes[swimlane_to_be_deleted.id]['ordered_uss']) + ordered_swimlanes[move_to_swimlane.id]['swimlane_id'].extend( + ordered_swimlanes[swimlane_to_be_deleted.id]['swimlane_id']) + ordered_swimlanes.pop(swimlane_to_be_deleted.id) + + # compose a flat list with the uss ordered + # and its equivalent with the corresponding swimlanes + for k, v in ordered_swimlanes.items(): + ordered_uss_ids.extend(v['ordered_uss']) + ordered_swimlane_ids.extend(v['swimlane_id']) + + # compose a list of tuples with the new order to make a bulk update + new_indexes = range(0, len(ordered_uss_ids)) + data = list(zip(ordered_swimlane_ids, ordered_uss_ids, new_indexes)) + + with connection.cursor() as curs: + execute_values(curs, + """ + UPDATE userstories_userstory + SET kanban_order = tmp.new_order, + swimlane_id = tmp.sid + FROM (VALUES %s) AS tmp (sid, ussid, new_order) + WHERE tmp.ussid = userstories_userstory.id""", + data) diff --git a/taiga/projects/services/filters.py b/taiga/projects/services/filters.py new file mode 100644 index 000000000..c4be4a17d --- /dev/null +++ b/taiga/projects/services/filters.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from contextlib import closing +from django.db import connection + + +def _get_project_tags(project): + result = set() + tags = project.tags or [] + for tag in tags: + result.add(tag) + return result + + +def _get_stories_tags(project): + result = set() + for tags in project.user_stories.values_list("tags", flat=True): + if tags: + result.update(tags) + return result + + +def _get_tasks_tags(project): + result = set() + for tags in project.tasks.values_list("tags", flat=True): + if tags: + result.update(tags) + return result + + +def _get_issues_tags(project): + result = set() + for tags in project.issues.values_list("tags", flat=True): + if tags: + result.update(tags) + return result + + +# Public api + +def get_all_tags(project): + """ + Given a project, return sorted list of unique + tags found on it. + """ + result = set() + result.update(_get_project_tags(project)) + result.update(_get_issues_tags(project)) + result.update(_get_stories_tags(project)) + result.update(_get_tasks_tags(project)) + return sorted(result) diff --git a/taiga/projects/services/invitations.py b/taiga/projects/services/invitations.py new file mode 100644 index 000000000..f6f3f0866 --- /dev/null +++ b/taiga/projects/services/invitations.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import transaction as tx +from django.db import IntegrityError +from django.utils.translation import gettext as _ + +from taiga.base import exceptions as exc + +from taiga.base.mails import mail_builder + + +def send_invitation(invitation): + """Send an invitation email""" + if invitation.user: + template = mail_builder.membership_notification + email = template(invitation.user, {"membership": invitation}) + else: + template = mail_builder.membership_invitation + email = template(invitation.email, {"membership": invitation}) + + email.send() + + +def find_invited_user(email, default=None): + """Check if the invited user is already a registered. + + :param email: some user email + :param default: Default object to return if user is not found. + + :return: The user if it's found, othwerwise return `default`. + """ + + User = apps.get_model(settings.AUTH_USER_MODEL) + qs = User.objects.filter(email__iexact=email) + + if len(qs) > 1: + qs = qs.filter(email=email) + + if len(qs) == 0: + return default + + return qs[0] + + +def get_membership_by_token(token:str): + """ + Given an invitation token, returns a membership instance + that matches with specified token. + + If not matches with any membership NotFound exception + is raised. + """ + membership_model = apps.get_model("projects", "Membership") + qs = membership_model.objects.filter(token=token) + if len(qs) == 0: + raise exc.NotFound(_("Token does not match any valid invitation.")) + return qs[0] + + +@tx.atomic +def accept_invitation_by_existing_user(token:str, user_id:int): + user_model = get_user_model() + try: + user = user_model.objects.get(id=user_id) + except user_model.DoesNotExist: + raise exc.NotFound(_("User does not exist.")) + + membership = get_membership_by_token(token) + try: + membership.user = user + membership.save(update_fields=["user"]) + except IntegrityError: + raise exc.IntegrityError(_("This user is already a member of the project.")) + return user diff --git a/taiga/projects/services/logo.py b/taiga/projects/services/logo.py new file mode 100644 index 000000000..cf9889846 --- /dev/null +++ b/taiga/projects/services/logo.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings + +from taiga.base.utils.thumbnails import get_thumbnail_url + + +def get_logo_small_thumbnail_url(project): + if project.logo: + return get_thumbnail_url(project.logo, settings.THN_LOGO_SMALL) + return None + + +def get_logo_big_thumbnail_url(project): + if project.logo: + return get_thumbnail_url(project.logo, settings.THN_LOGO_BIG) + return None diff --git a/taiga/projects/services/members.py b/taiga/projects/services/members.py new file mode 100644 index 000000000..cdf769b57 --- /dev/null +++ b/taiga/projects/services/members.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from taiga.base.exceptions import ValidationError, WrongArguments +from taiga.base.utils import db +from taiga.users.models import User +from taiga.users.services import get_user_by_username_or_email + +from django.conf import settings +from django.core.validators import validate_email +from django.utils.translation import gettext as _ + +from .. import models + + +def get_members_from_bulk(bulk_data, **additional_fields): + """Convert `bulk_data` into a list of members. + + :param bulk_data: List of members in bulk format. + :param additional_fields: Additional fields when instantiating each task. + + :return: List of `Member` instances. + """ + members = [] + + for data in bulk_data: + data_copy = data.copy() + + # Try to find the user + username = data_copy.pop("username") + try: + user = get_user_by_username_or_email(username) + data_copy["user_id"] = user.id + except WrongArguments: + # If not exist, is an invitation. Set the email and a token + try: + validate_email(username) + data_copy["email"] = username + except ValidationError: + raise WrongArguments(_("Malformed email adress.")) + + if "token" not in data_copy.keys(): + data_copy["token"] = str(uuid.uuid1()) + + data_copy.update(additional_fields) + + members.append(models.Membership(**data_copy)) + + return members + + +def create_members_in_bulk(members, callback=None, precall=None): + """Create members from `bulk_data`. + + :param members: List of dicts `{"project_id": <>, "role_id": <>, "username": <>}`. + :param callback: Callback to execute after each task save. + :param additional_fields: Additional fields when instantiating each task. + + """ + db.save_in_bulk(members, callback, precall) + + +def remove_user_from_project(user, project): + models.Membership.objects.get(project=project, user=user).delete() + + +def project_has_valid_admins(project, exclude_user=None): + """ + Checks if the project has any owner membership with a user different than the specified + """ + admin_memberships = project.memberships.filter(is_admin=True, user__is_active=True) + if exclude_user: + admin_memberships = admin_memberships.exclude(user=exclude_user) + + return admin_memberships.count() > 0 + + +def can_user_leave_project(user, project): + membership = project.memberships.get(user=user) + if not membership.is_admin: + return True + + # The user can't leave if is the real owner of the project + if project.owner == user: + return False + + if not project_has_valid_admins(project, exclude_user=user): + return False + + return True + + +def get_max_memberships_for_project(project): + """Return tha maximun of membersh for a concrete project. + + :param project: A project object. + + :return: a number or null. + """ + if project.owner is None: + return None + + if project.is_private: + return project.owner.max_memberships_private_projects + return project.owner.max_memberships_public_projects + + +def get_total_project_memberships(project): + """Return tha total of memberships of a project (members and unaccepted invitations). + + :param project: A project object. + + :return: a number. + """ + return project.memberships.count() + + +def check_if_new_member_can_be_created(new_membership): + """Return if a new mebership could be created. + + :param new_memberships: the new membershhip object (not saved in the database jet) + + :return: {bool, error_mesage, total_memberships} return a tuple (can add new members?, error message, total of members). + """ + project = new_membership.project + + if project.owner is None: + return False, _("Project without owner"), None + + confirmed_memberships = (models.Membership.objects.filter(project__is_private=project.is_private, + project__owner_id=project.owner_id, + user_id__isnull=False) + .order_by("user_id") + .distinct("user_id") + .count()) # Just confirmed members + pending_memberships = (models.Membership.objects.filter(project__is_private=project.is_private, + project__owner_id=project.owner_id, + user_id__isnull=True) + .order_by("email") + .distinct("email") + .count()) # Pending members + if new_membership.user_id: + confirmed_memberships +=1 + else: + pending_memberships +=1 + + total_memberships = confirmed_memberships + pending_memberships + + if project.is_private: + max_memberships = project.owner.max_memberships_private_projects + error_members_exceeded = _("You have reached your current limit of memberships for private projects") + else: + max_memberships = project.owner.max_memberships_public_projects + error_members_exceeded = _("You have reached your current limit of memberships for public projects") + + if max_memberships is not None and total_memberships > max_memberships: + return False, error_members_exceeded, total_memberships + + if not new_membership.user_id and project.memberships.filter(user=None).count() + 1 > settings.MAX_PENDING_MEMBERSHIPS: + error_pending_memberships_exceeded = _("You have reached the current limit of pending memberships") + return False, error_pending_memberships_exceeded, pending_memberships + + return True, None, total_memberships + + +def check_if_new_members_can_be_created(project, new_memberships): + """Return if some new meberships could be created. + + :param project: the common projects for all the memberships + :param new_memberships: a list with the new membershhips object (not saved in the database jet) + + :return: {bool, error_mesage, total_memberships} return a tuple (can add new members?, error message, total of members). + """ + if project.owner is None: + return False, _("Project without owner"), None + + new_confirmed_memberships = [m.user_id for m in new_memberships if m.user_id] + new_pending_memberships = [m.email for m in new_memberships if not m.user_id] + + confirmed_memberships = (models.Membership.objects.filter(project__is_private=project.is_private, + project__owner_id=project.owner_id, + user_id__isnull=False) + .order_by("user_id") + .distinct("user_id") + .values_list("user_id", flat=True)) # Just confirmed members + pending_memberships = (models.Membership.objects.filter(project__is_private=project.is_private, + project__owner_id=project.owner_id, + user_id__isnull=True) + .order_by("email") + .distinct("email") + .values_list("email", flat=True)) + + total_memberships = len({*pending_memberships, *new_pending_memberships, *confirmed_memberships, *new_confirmed_memberships}) + total_pending_memberships = len({*pending_memberships, *new_pending_memberships}) + + if project.is_private: + max_memberships = project.owner.max_memberships_private_projects + error_members_exceeded = _("You have reached your current limit of memberships for private projects") + else: + max_memberships = project.owner.max_memberships_public_projects + error_members_exceeded = _("You have reached your current limit of memberships for public projects") + + if max_memberships is not None and total_memberships > max_memberships: + return False, error_members_exceeded, total_memberships + + if new_pending_memberships and project.memberships.filter(user=None).count() + len(new_pending_memberships) > settings.MAX_PENDING_MEMBERSHIPS: + error_pending_memberships_exceeded = _("You have reached the current limit of pending memberships") + return False, error_pending_memberships_exceeded, pending_memberships + + return True, None, total_memberships diff --git a/taiga/projects/services/modules_config.py b/taiga/projects/services/modules_config.py new file mode 100644 index 000000000..2a20b7228 --- /dev/null +++ b/taiga/projects/services/modules_config.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import importlib + +from .. import models +from django.conf import settings + + +def get_modules_config(project): + modules_config, created = models.ProjectModulesConfig.objects.get_or_create(project=project) + + if created or modules_config.config == None: + modules_config.config = {} + + for key, configurator_function_name in settings.PROJECT_MODULES_CONFIGURATORS.items(): + mod_name, func_name = configurator_function_name.rsplit('.',1) + mod = importlib.import_module(mod_name) + configurator = getattr(mod, func_name) + modules_config.config[key] = configurator(project) + + modules_config.save() + return modules_config diff --git a/taiga/projects/services/projects.py b/taiga/projects/services/projects.py new file mode 100644 index 000000000..602a07e7c --- /dev/null +++ b/taiga/projects/services/projects.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.db.models import Q +from django.utils.translation import gettext as _ +from taiga.celery import app +from taiga.base.api.utils import get_object_or_404 +from taiga.permissions import services as permissions_services +from taiga.projects.history.services import take_snapshot + +from .. import choices +from ..apps import connect_projects_signals, disconnect_projects_signals + + +ERROR_MAX_PUBLIC_PROJECTS_MEMBERSHIPS = 'max_public_projects_memberships' +ERROR_MAX_PRIVATE_PROJECTS_MEMBERSHIPS = 'max_private_projects_memberships' +ERROR_MAX_PUBLIC_PROJECTS = 'max_public_projects' +ERROR_MAX_PRIVATE_PROJECTS = 'max_private_projects' +ERROR_PROJECT_WITHOUT_OWNER = 'project_without_owner' + + +def check_if_project_privacy_can_be_changed(project): + """Return if the project privacy can be changed from private to public or viceversa. + + :param project: A project object. + + :return: A dict like this {'can_be_updated': bool, 'reason': error message}. + """ + if project.owner is None: + return {'can_be_updated': False, 'reason': ERROR_PROJECT_WITHOUT_OWNER} + + current_projects = (project.owner.owned_projects.filter(is_private=not project.is_private) + .count()) + Membership = apps.get_model("projects", "Membership") + current_memberships = (Membership.objects.filter(Q(project__is_private=not project.is_private, # public/private members + project__owner_id=project.owner_id, + user_id__isnull=False) | + Q(project_id=project.pk, # current project members + user_id__isnull=False)) + .values("user_id") + .distinct() + .count()) # Just confirmed members + + current_memberships += (Membership.objects.filter(Q(project__is_private=not project.is_private, # public/private members + project__owner_id=project.owner_id, + user_id__isnull=True) | + Q(project_id=project.pk, # current project members + user_id__isnull=True)) + .values("email") + .distinct() + .count()) # Just pending members + + if project.is_private: + max_projects = project.owner.max_public_projects + max_memberships = project.owner.max_memberships_public_projects + error_project_exceeded = ERROR_MAX_PUBLIC_PROJECTS + error_memberships_exceeded = ERROR_MAX_PUBLIC_PROJECTS_MEMBERSHIPS + else: + max_projects = project.owner.max_private_projects + max_memberships = project.owner.max_memberships_private_projects + error_project_exceeded = ERROR_MAX_PRIVATE_PROJECTS + error_memberships_exceeded = ERROR_MAX_PRIVATE_PROJECTS_MEMBERSHIPS + + if max_memberships is not None and current_memberships > max_memberships: + return {'can_be_updated': False, 'reason': error_memberships_exceeded} + + if max_projects is not None and current_projects >= max_projects: + return {'can_be_updated': False, 'reason': error_project_exceeded} + + return {'can_be_updated': True, 'reason': None} + + +def check_if_project_can_be_created_or_updated(project): + """Return if the project can be create or update (the privacy). + + :param project: A project object. + + :return: (bool, error_mesage, int) return a tuple (can be duplicated, error message, total new project members). + """ + if project.owner is None: + return (False, ERROR_PROJECT_WITHOUT_OWNER, 0) + + current_projects = project.owner.owned_projects.filter(is_private=project.is_private).count() + Membership = apps.get_model("projects", "Membership") + current_memberships = (Membership.objects.filter(project__is_private=project.is_private, + project__owner_id=project.owner_id, + user_id__isnull=False) + .values("user_id") + .distinct() + .count()) # Just confirmed members + current_memberships += (Membership.objects.filter(project__is_private=project.is_private, + project__owner_id=project.owner_id, + user_id__isnull=True) + .values("email") + .distinct() + .count()) # Pending members + + if project.is_private: + max_projects = project.owner.max_private_projects + max_memberships = project.owner.max_memberships_private_projects + error_project_exceeded = _("You can't have more private projects") + error_memberships_exceeded = _("This project reaches your current limit of memberships for private projects") + else: + max_projects = project.owner.max_public_projects + max_memberships = project.owner.max_memberships_public_projects + error_project_exceeded = _("You can't have more public projects") + error_memberships_exceeded = _("This project reaches your current limit of memberships for public projects") + + if max_projects is not None and current_projects >= max_projects: + return (False, error_project_exceeded, current_memberships) + + if max_memberships is not None and current_memberships > max_memberships: + return (False, error_memberships_exceeded, current_memberships) + + return (True, None, current_memberships) + + +def check_if_project_can_be_transfered(project, new_owner): + """Return if the project can be transfered to another member. + + :param project: A project object. + :param new_owner: The new owner. + + :return: (bool, error_mesage, int) return a tuple (can be duplicated, error message, total new project members). + """ + if project.owner == new_owner: + return (True, None, 0) + + current_projects = new_owner.owned_projects.filter(is_private=project.is_private).count() + Membership = apps.get_model("projects", "Membership") + current_memberships = (Membership.objects.filter(Q(project__is_private=project.is_private, # public/private members + project__owner_id=new_owner.id, + user_id__isnull=False) | + Q(project_id=project.pk, # current project members + user_id__isnull=False)) + .values("user_id") + .distinct() + .count()) # Just confirmed members + current_memberships += (Membership.objects.filter(Q(project__is_private=project.is_private, # public/private members + project__owner_id=new_owner.id, + user_id__isnull=True) | + Q(project_id=project.pk, # current project members + user_id__isnull=True)) + .values("email") + .distinct() + .count()) # Pending members + + if project.is_private: + max_projects = new_owner.max_private_projects + max_memberships = new_owner.max_memberships_private_projects + error_project_exceeded = _("You can't have more private projects") + error_memberships_exceeded = _("This project reaches your current limit of memberships for private projects") + else: + max_projects = new_owner.max_public_projects + max_memberships = new_owner.max_memberships_public_projects + error_project_exceeded = _("You can't have more public projects") + error_memberships_exceeded = _("This project reaches your current limit of memberships for public projects") + + if max_projects is not None and current_projects >= max_projects: + return (False, error_project_exceeded, current_memberships) + + if max_memberships is not None and current_memberships > max_memberships: + return (False, error_memberships_exceeded, current_memberships) + + return (True, None, current_memberships) + + +def check_if_project_can_be_duplicate(project, new_owner, new_is_private, new_user_id_members): + """Return if the project can be duplicate. + + :param project: A project object. + :param new_owner: The new owner. + :param new_is_private: 'True' if new project will be private. + :param new_user_id_members: A list of user ids for new members. + + :return: (bool, error_mesage, int) return a tuple (can be duplicated, error message, total new project members). + """ + current_projects = new_owner.owned_projects.filter(is_private=project.is_private).count() + Membership = apps.get_model("projects", "Membership") + actual_user_id_members = (Membership.objects.filter(project__is_private=new_is_private, + project__owner_id=new_owner.id, + user_id__isnull=False) + .values("user_id") + .distinct() + .values_list("user__id", flat=True)) + total_pending_members = (Membership.objects.filter(project__is_private=new_is_private, + project__owner_id=new_owner.id, + user_id__isnull=True) + .values("email") + .distinct() + .count()) + + current_memberships = len( + set(list(actual_user_id_members) + new_user_id_members) + - set([new_owner.id]) # remove owner if exist, maybe not + ) + current_memberships += 1 # +1 for new owner + current_memberships += total_pending_members + + if new_is_private: + max_projects = new_owner.max_private_projects + max_memberships = new_owner.max_memberships_private_projects + error_project_exceeded = _("You can't have more private projects") + error_memberships_exceeded = _("This project reaches your current limit of memberships for private projects") + else: + max_projects = new_owner.max_public_projects + max_memberships = new_owner.max_memberships_public_projects + error_project_exceeded = _("You can't have more public projects") + error_memberships_exceeded = _("This project reaches your current limit of memberships for public projects") + + if max_projects is not None and current_projects >= max_projects: + return (False, error_project_exceeded, current_memberships) + + if max_memberships is not None and current_memberships > max_memberships: + return (False, error_memberships_exceeded, current_memberships) + + return (True, None, current_memberships) + + +def check_if_project_is_out_of_owner_limits(project): + """Return if the project fits on its owner limits. + + :param project: A project object. + + :return: bool + """ + if project.owner is None: + return False + + current_projects = project.owner.owned_projects.filter(is_private=project.is_private).count() + Membership = apps.get_model("projects", "Membership") + current_memberships = (Membership.objects.filter(project__is_private=project.is_private, + project__owner_id=project.owner_id, + user_id__isnull=False) + .values("user_id") + .distinct() + .count()) # Current confirmed members + current_memberships += (Membership.objects.filter(project__is_private=project.is_private, + project__owner_id=project.owner_id, + user_id__isnull=True) + .values("email") + .distinct() + .count()) # Pending members + + if project.is_private: + max_projects = project.owner.max_private_projects + max_memberships = project.owner.max_memberships_private_projects + else: + max_projects = project.owner.max_public_projects + max_memberships = project.owner.max_memberships_public_projects + + if max_memberships is not None and current_memberships > max_memberships: + return True + + if max_projects is not None and current_projects > max_projects: + return True + + return False + + +def orphan_project(project): + project.memberships.filter(user=project.owner).delete() + project.owner = None + project.blocked_code = choices.BLOCKED_BY_DELETING + project.save() + + +@app.task +def delete_project(project_id): + Project = apps.get_model("projects", "Project") + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return + + project.delete_related_content() + project.delete() + + +@app.task +def delete_projects(projects): + for project in projects: + delete_project(project.id) + + +def duplicate_project(project, **new_project_extra_args): + owner = new_project_extra_args.get("owner") + users = new_project_extra_args.pop("users") + + disconnect_projects_signals() + Project = apps.get_model("projects", "Project") + new_project = Project.objects.create(**new_project_extra_args) + connect_projects_signals() + + permissions_services.set_base_permissions_for_project(new_project) + + # Cloning the structure from the old project using templates + Template = apps.get_model("projects", "ProjectTemplate") + template = Template() + template.load_data_from_project(project) + template.apply_to_project(new_project) + new_project.creation_template = project.creation_template + new_project.save() + + # Creating the membership for the new owner + Membership = apps.get_model("projects", "Membership") + Membership.objects.create( + user=owner, + is_admin=True, + role=new_project.roles.get(slug=template.default_owner_role), + project=new_project + ) + + # Creating the extra memberships + for user in users: + project_memberships = project.memberships.exclude(user_id=owner.id) + membership = get_object_or_404(project_memberships, user_id=user["id"]) + Membership.objects.create( + user=membership.user, + is_admin=membership.is_admin, + role=new_project.roles.get(slug=membership.role.slug), + project=new_project + ) + + # Take initial snapshot for the project + take_snapshot(new_project, user=owner) + return new_project diff --git a/taiga/projects/services/promote.py b/taiga/projects/services/promote.py new file mode 100644 index 000000000..678064912 --- /dev/null +++ b/taiga/projects/services/promote.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.apps import apps +from django.core.files.base import ContentFile +from django.db.models import signals +from django.utils.translation import gettext_lazy as _ + +from taiga.projects.attachments.models import Attachment +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.services import (get_history_queryset_by_model_instance, + make_key_from_model_object) +from taiga.projects.issues.models import Issue +from taiga.projects.notifications.models import Watched +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.votes.models import Vote, Votes + + +def promote_to_us(source_obj): + model_class = source_obj.__class__ + queryset = model_class.objects.filter(pk=source_obj.id) + queryset = queryset.prefetch_related("attachments") + queryset = queryset.select_related("owner", + "assigned_to", + "project") + queryset = attach_watchers_to_queryset(queryset) + + us_refs = [] + for obj in queryset: + us = UserStory.objects.create( + generated_from_issue_id=obj.id if isinstance(source_obj, Issue) else None, + from_task_ref = _("Task #%(ref)s") % {"ref": obj.ref} if isinstance(source_obj, Task) else None, + project=obj.project, + owner=obj.owner, + subject=obj.subject, + description=obj.description, + tags=obj.tags, + milestone=obj.milestone, + ) + + content_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(us) + + # add data only for task conversion + if isinstance(source_obj, Task): + us.due_date = obj.due_date + us.due_date_reason = obj.due_date_reason + us.is_blocked = obj.is_blocked + us.blocked_note = obj.blocked_note + us.save() + + _import_votes(obj, us) + + _import_assigned(obj, us) + _import_comments(obj, us) + _import_attachments(obj, us, content_type) + _import_watchers(obj, us, content_type) + + us_refs.append(us.ref) + + return us_refs + + +def _import_assigned(source_obj, target_obj): + if source_obj.assigned_to: + target_obj.assigned_users.add(source_obj.assigned_to) + + +def _import_comments(source_obj, target_obj): + pre_save = signals.pre_save.receivers + post_save = signals.post_save.receivers + signals.pre_save.receivers = [] + signals.post_save.receivers = [] + + comments = ( + get_history_queryset_by_model_instance(source_obj) + .exclude(comment__exact='') + ) + us_key = make_key_from_model_object(target_obj) + + for entry in comments.all(): + HistoryEntry.objects.create( + user=entry.user, + project_id=entry.project_id, + key=us_key, + type=entry.type, + snapshot=None, + diff=entry.diff, + values=entry.values, + comment=entry.comment, + comment_html=entry.comment_html, + delete_comment_date=entry.delete_comment_date, + delete_comment_user=entry.delete_comment_user, + is_hidden=False, + is_snapshot=False, + created_at=entry.created_at + ) + + signals.pre_save.receivers = pre_save + signals.post_save.receivers = post_save + + +def _import_watchers(source_obj, target_obj, content_type): + + for watcher_id in source_obj.watchers: + Watched.objects.create( + content_type=content_type, + object_id=target_obj.id, + user_id=watcher_id, + project=source_obj.project) + + +def _import_attachments(source_obj, target_obj, content_type): + for attachment in source_obj.attachments.all(): + att = Attachment( + owner=attachment.owner, + project=attachment.project, + content_type=content_type, + object_id=target_obj.id, + name=attachment.name, + size=attachment.size, + created_date=attachment.created_date, + is_deprecated=attachment.is_deprecated, + description=attachment.description, + ) + attached_file = attachment.attached_file + att.attached_file.save(attached_file.name, ContentFile(attached_file.read()), save=True) + + +def _import_votes(source_obj, target_obj): + source_content_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(source_obj) + target_content_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(target_obj) + ( + Vote.objects + .filter(content_type=source_content_type, object_id=source_obj.id) + .update(content_type=target_content_type, object_id=target_obj.id) + ) + + ( + Votes.objects + .filter(content_type=source_content_type, object_id=source_obj.id) + .update(content_type=target_content_type, object_id=target_obj.id) + ) diff --git a/taiga/projects/services/stats.py b/taiga/projects/services/stats.py new file mode 100644 index 000000000..5ad648692 --- /dev/null +++ b/taiga/projects/services/stats.py @@ -0,0 +1,427 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ +from django.db.models import Q, Count +from django.apps import apps +import datetime +import copy +import collections + + +def _count_status_object(status_obj, counting_storage): + if status_obj.id in counting_storage: + counting_storage[status_obj.id]['count'] += 1 + else: + counting_storage[status_obj.id] = {} + counting_storage[status_obj.id]['count'] = 1 + counting_storage[status_obj.id]['name'] = status_obj.name + counting_storage[status_obj.id]['id'] = status_obj.id + counting_storage[status_obj.id]['color'] = status_obj.color + + +def _count_owned_object(user_obj, counting_storage): + if user_obj: + if user_obj.id in counting_storage: + counting_storage[user_obj.id]['count'] += 1 + else: + counting_storage[user_obj.id] = {} + counting_storage[user_obj.id]['count'] = 1 + counting_storage[user_obj.id]['username'] = user_obj.username + counting_storage[user_obj.id]['name'] = user_obj.get_full_name() + counting_storage[user_obj.id]['id'] = user_obj.id + counting_storage[user_obj.id]['color'] = user_obj.color + else: + if 0 in counting_storage: + counting_storage[0]['count'] += 1 + else: + counting_storage[0] = {} + counting_storage[0]['count'] = 1 + counting_storage[0]['username'] = _('Unassigned') + counting_storage[0]['name'] = _('Unassigned') + counting_storage[0]['id'] = 0 + counting_storage[0]['color'] = 'black' + + +def get_stats_for_project_issues(project): + project_issues_stats = { + 'total_issues': 0, + 'opened_issues': 0, + 'closed_issues': 0, + 'issues_per_type': {}, + 'issues_per_status': {}, + 'issues_per_priority': {}, + 'issues_per_severity': {}, + 'issues_per_owner': {}, + 'issues_per_assigned_to': {}, + 'last_four_weeks_days': { + 'by_open_closed': {'open': [], 'closed': []}, + 'by_severity': {}, + 'by_priority': {}, + 'by_status': {}, + } + + } + + issues = project.issues.all().select_related( + 'status', 'priority', 'type', 'severity', 'owner', 'assigned_to' + ) + for issue in issues: + project_issues_stats['total_issues'] += 1 + if issue.status is not None and issue.status.is_closed: + project_issues_stats['closed_issues'] += 1 + else: + project_issues_stats['opened_issues'] += 1 + _count_status_object(issue.type, project_issues_stats['issues_per_type']) + _count_status_object(issue.status, project_issues_stats['issues_per_status']) + _count_status_object(issue.priority, project_issues_stats['issues_per_priority']) + _count_status_object(issue.severity, project_issues_stats['issues_per_severity']) + _count_owned_object(issue.owner, project_issues_stats['issues_per_owner']) + _count_owned_object(issue.assigned_to, project_issues_stats['issues_per_assigned_to']) + + for severity in project_issues_stats['issues_per_severity'].values(): + project_issues_stats['last_four_weeks_days']['by_severity'][severity['id']] = copy.copy(severity) + del(project_issues_stats['last_four_weeks_days']['by_severity'][severity['id']]['count']) + project_issues_stats['last_four_weeks_days']['by_severity'][severity['id']]['data'] = [] + + for priority in project_issues_stats['issues_per_priority'].values(): + project_issues_stats['last_four_weeks_days']['by_priority'][priority['id']] = copy.copy(priority) + del(project_issues_stats['last_four_weeks_days']['by_priority'][priority['id']]['count']) + project_issues_stats['last_four_weeks_days']['by_priority'][priority['id']]['data'] = [] + + for x in range(27, -1, -1): + day = datetime.datetime.combine(datetime.date.today(), datetime.time(0, 0)) - datetime.timedelta(days=x) + next_day = day + datetime.timedelta(days=1) + + open_this_day = filter(lambda x: x.created_date.replace(tzinfo=None) >= day, issues) + open_this_day = filter(lambda x: x.created_date.replace(tzinfo=None) < next_day, open_this_day) + open_this_day = len(list(open_this_day)) + project_issues_stats['last_four_weeks_days']['by_open_closed']['open'].append(open_this_day) + + closed_this_day = filter(lambda x: x.finished_date, issues) + closed_this_day = filter(lambda x: x.finished_date.replace(tzinfo=None) >= day, closed_this_day) + closed_this_day = filter(lambda x: x.finished_date.replace(tzinfo=None) < next_day, closed_this_day) + closed_this_day = len(list(closed_this_day)) + project_issues_stats['last_four_weeks_days']['by_open_closed']['closed'].append(closed_this_day) + + opened_this_day = filter(lambda x: x.created_date.replace(tzinfo=None) < next_day, issues) + opened_this_day = list(filter(lambda x: x.finished_date is None or x.finished_date.replace(tzinfo=None) > day, opened_this_day)) + + for severity in project_issues_stats['last_four_weeks_days']['by_severity']: + by_severity = filter(lambda x: x.severity_id == severity, opened_this_day) + by_severity = len(list(by_severity)) + project_issues_stats['last_four_weeks_days']['by_severity'][severity]['data'].append(by_severity) + + for priority in project_issues_stats['last_four_weeks_days']['by_priority']: + by_priority = filter(lambda x: x.priority_id == priority, opened_this_day) + by_priority = len(list(by_priority)) + project_issues_stats['last_four_weeks_days']['by_priority'][priority]['data'].append(by_priority) + + return project_issues_stats + + +def _get_milestones_stats_for_backlog(project, milestones): + """ + Calculates the stats associated to the milestones parameter. + + - project is a Project model instance + We assume this object have also the following numeric attributes: + - _defined_points + - _future_team_increment + - _future_client_increment + + - milestones is a sorted dict of Milestone model instances sorted by estimated_start. + We assume this objects have also the following numeric attributes: + - _closed_points + - _team_increment_points + - _client_increment_points + + The returned result is a list of dicts where each entry contains the following keys: + - name + - optimal + - evolution + - team + - client + """ + current_evolution = 0 + current_team_increment = 0 + current_client_increment = 0 + optimal_points_per_sprint = 0 + optimal_points = 0 + team_increment = 0 + client_increment = 0 + + total_story_points = project.total_story_points\ + if project.total_story_points not in [None, 0] else project._defined_points + + total_milestones = project.total_milestones\ + if project.total_milestones not in [None, 0] else len(milestones) + + if total_story_points and total_milestones: + optimal_points_per_sprint = total_story_points / total_milestones + + milestones_count = len(milestones) + milestones_stats = [] + for current_milestone_pos in range(0, max(milestones_count, total_milestones)): + optimal_points = (total_story_points - + (optimal_points_per_sprint * current_milestone_pos)) + + evolution = (total_story_points - current_evolution + if current_evolution is not None else None) + + if current_milestone_pos < milestones_count: + current_milestone = list(milestones.values())[current_milestone_pos] + milestone_name = current_milestone.name + team_increment = current_team_increment + client_increment = current_client_increment + current_evolution += current_milestone._closed_points + current_team_increment += current_milestone._team_increment_points + current_client_increment += current_milestone._client_increment_points + + else: + milestone_name = _("Future sprint") + current_team_increment += project._future_team_increment + current_client_increment += project._future_client_increment + team_increment = current_team_increment + client_increment = current_client_increment + current_evolution = None + + milestones_stats.append({ + 'name': milestone_name, + 'optimal': optimal_points, + 'evolution': evolution, + 'team-increment': team_increment, + 'client-increment': client_increment, + }) + + optimal_points -= optimal_points_per_sprint + evolution = (total_story_points - current_evolution + if current_evolution is not None and total_story_points else None) + + milestones_stats.append({ + 'name': _('Project End'), + 'optimal': optimal_points, + 'evolution': evolution, + 'team-increment': current_team_increment, + 'client-increment': current_client_increment, + }) + + return milestones_stats + + +def get_stats_for_project(project): + # Let's fetch all the estimations related to a project with all the necesary + # related data + RolePoints = apps.get_model('userstories', 'RolePoints') + role_points = RolePoints.objects.filter( + user_story__project = project, + ).prefetch_related( + "user_story", + "user_story__assigned_to", + "user_story__milestone", + "user_story__status", + "role", + "points") + + # Data inicialization + project._closed_points = 0 + project._closed_points_per_role = {} + project._closed_points_from_closed_milestones = 0 + project._defined_points = 0 + project._defined_points_per_role = {} + project._assigned_points = 0 + project._assigned_points_per_role = {} + project._future_team_increment = 0 + project._future_client_increment = 0 + + # The key will be the milestone id and it will be ordered by estimated_start + milestones = collections.OrderedDict() + for milestone in project.milestones.order_by("estimated_start"): + milestone._closed_points = 0 + milestone._team_increment_points = 0 + milestone._client_increment_points = 0 + milestones[milestone.id] = milestone + + def _find_milestone_for_userstory(user_story): + for m in milestones.values(): + if m.estimated_finish > user_story.created_date.date() and\ + m.estimated_start <= user_story.created_date.date(): + + return m + + return None + + def _update_team_increment(milestone, value): + if milestone: + milestones[milestone.id]._team_increment_points += value + else: + project._future_team_increment += value + + def _update_client_increment(milestone, value): + if milestone: + milestones[milestone.id]._client_increment_points += value + else: + project._future_client_increment += value + + # Iterate over all the project estimations and update our stats + for role_point in role_points: + role_id = role_point.role.id + points_value = role_point.points.value + user_story = getattr(role_point, "user_story", None) + + milestone = None + is_team_requirement = None + is_client_requirement = None + us_milestone = None + + if user_story: + milestone = user_story.milestone + is_team_requirement = user_story.team_requirement + is_client_requirement = user_story.client_requirement + us_milestone = _find_milestone_for_userstory(user_story) + + # None estimations doesn't affect to project stats + if points_value is None: + continue + + # Total defined points + project._defined_points += points_value + + # Defined points per role + project._defined_points_for_role = project._defined_points_per_role.get(role_id, 0) + project._defined_points_for_role += points_value + project._defined_points_per_role[role_id] = project._defined_points_for_role + + # Closed points + if user_story and user_story.is_closed: + project._closed_points += points_value + closed_points_for_role = project._closed_points_per_role.get(role_id, 0) + closed_points_for_role += points_value + project._closed_points_per_role[role_id] = closed_points_for_role + + if milestone is not None: + milestones[milestone.id]._closed_points += points_value + + if milestone is not None and milestone.closed: + project._closed_points_from_closed_milestones += points_value + + # Assigned to milestone points + if user_story and user_story.milestone is not None: + project._assigned_points += points_value + assigned_points_for_role = project._assigned_points_per_role.get(role_id, 0) + assigned_points_for_role += points_value + project._assigned_points_per_role[role_id] = assigned_points_for_role + + # Extra requirements + if is_team_requirement and is_client_requirement: + _update_team_increment(us_milestone, points_value/2) + _update_client_increment(us_milestone, points_value/2) + + if is_team_requirement and not is_client_requirement: + _update_team_increment(us_milestone, points_value) + + if not is_team_requirement and is_client_requirement: + _update_client_increment(us_milestone, points_value) + + # Speed calculations + speed = 0 + closed_milestones = len([m for m in milestones.values() if m.closed]) + if closed_milestones != 0: + speed = project._closed_points_from_closed_milestones / closed_milestones + + milestones_stats = _get_milestones_stats_for_backlog(project, milestones) + + project_stats = { + 'name': project.name, + 'total_milestones': project.total_milestones, + 'total_points': project.total_story_points, + 'closed_points': project._closed_points, + 'closed_points_per_role': project._closed_points_per_role, + 'defined_points': project._defined_points, + 'defined_points_per_role': project._defined_points_per_role, + 'assigned_points': project._assigned_points, + 'assigned_points_per_role': project._assigned_points_per_role, + 'milestones': milestones_stats, + 'speed': speed, + } + return project_stats + + +def _get_closed_bugs_per_member_stats(project): + # Closed bugs per user + closed_bugs = project.issues.filter(status__is_closed=True)\ + .values('assigned_to')\ + .annotate(count=Count('assigned_to'))\ + .order_by() + closed_bugs = { p["assigned_to"]: p["count"] for p in closed_bugs} + return closed_bugs + + +def _get_iocaine_tasks_per_member_stats(project): + # Iocaine tasks assigned per user + iocaine_tasks = project.tasks.filter(is_iocaine=True)\ + .values('assigned_to')\ + .annotate(count=Count('assigned_to'))\ + .order_by() + iocaine_tasks = { t["assigned_to"]: t["count"] for t in iocaine_tasks} + return iocaine_tasks + + +def _get_wiki_changes_per_member_stats(project): + # Wiki changes + wiki_changes = {} + wiki_page_keys = ["wiki.wikipage:%s"%id for id in project.wiki_pages.values_list("id", flat=True)] + HistoryEntry = apps.get_model('history', 'HistoryEntry') + history_entries = HistoryEntry.objects.filter(key__in=wiki_page_keys).values('user') + for entry in history_entries: + editions = wiki_changes.get(entry["user"]["pk"], 0) + wiki_changes[entry["user"]["pk"]] = editions + 1 + + return wiki_changes + + +def _get_created_bugs_per_member_stats(project): + # Created_bugs + created_bugs = project.issues\ + .values('owner')\ + .annotate(count=Count('owner'))\ + .order_by() + created_bugs = { p["owner"]: p["count"] for p in created_bugs } + return created_bugs + + +def _get_closed_tasks_per_member_stats(project): + # Closed tasks + closed_tasks = project.tasks.filter(status__is_closed=True)\ + .values('assigned_to')\ + .annotate(count=Count('assigned_to'))\ + .order_by() + closed_tasks = {p["assigned_to"]: p["count"] for p in closed_tasks} + return closed_tasks + + +def get_member_stats_for_project(project): + base_counters = {id: 0 for id in project.members.values_list("id", flat=True)} + closed_bugs = base_counters.copy() + closed_bugs.update(_get_closed_bugs_per_member_stats(project)) + iocaine_tasks = base_counters.copy() + iocaine_tasks.update(_get_iocaine_tasks_per_member_stats(project)) + wiki_changes = base_counters.copy() + wiki_changes.update(_get_wiki_changes_per_member_stats(project)) + created_bugs = base_counters.copy() + created_bugs.update(_get_created_bugs_per_member_stats(project)) + closed_tasks = base_counters.copy() + closed_tasks.update(_get_closed_tasks_per_member_stats(project)) + + member_stats = { + "closed_bugs": closed_bugs, + "iocaine_tasks": iocaine_tasks, + "wiki_changes": wiki_changes, + "created_bugs": created_bugs, + "closed_tasks": closed_tasks, + } + return member_stats diff --git a/taiga/projects/services/transfer.py b/taiga/projects/services/transfer.py new file mode 100644 index 000000000..7514e0844 --- /dev/null +++ b/taiga/projects/services/transfer.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core import signing +from django.utils.translation import gettext as _ + +import datetime + +from taiga.base.mails import mail_builder +from taiga.base import exceptions as exc + + +def request_project_transfer(project, user): + template = mail_builder.transfer_request + email = template(project.owner, {"project": project, "requester": user}) + email.send() + + +def start_project_transfer(project, user, reason): + """Generates the transfer token for a project transfer and notify to the destination user + + :param project: Project trying to transfer + :param user: Destination user + :param reason: Reason to transfer the project + """ + + signer = signing.TimestampSigner() + token = signer.sign(user.id) + project.transfer_token = token + project.save() + + template = mail_builder.transfer_start + context = { + "project": project, + "receiver": user, + "token": token, + "reason": reason + } + email = template(user, context) + email.send() + + +def validate_project_transfer_token(token, project, user): + signer = signing.TimestampSigner() + + if project.transfer_token != token: + raise exc.WrongArguments(_("Token is invalid")) + + try: + value = signer.unsign(token, max_age=datetime.timedelta(days=7)) + except signing.SignatureExpired: + raise exc.WrongArguments(_("Token has expired")) + except signing.BadSignature: + raise exc.WrongArguments(_("Token is invalid")) + + if str(value) != str(user.id): + raise exc.WrongArguments(_("Token is invalid")) + + +def reject_project_transfer(project, user, token, reason): + validate_project_transfer_token(token, project, user) + + project.transfer_token = None + project.save() + + template = mail_builder.transfer_reject + context = { + "project": project, + "rejecter": user, + "reason": reason + } + email = template(project.owner, context) + email.send() + + +def accept_project_transfer(project, user, token, reason): + validate_project_transfer_token(token, project, user) + + # Set new owner as project admin + membership = project.memberships.get(user=user) + if not membership.is_admin: + membership.is_admin = True + membership.save() + + # Change the owner of the project + old_owner = project.owner + project.transfer_token = None + project.owner = user + project.save() + + # Send mail + template = mail_builder.transfer_accept + context = { + "project": project, + "old_owner": old_owner, + "new_owner": user, + "reason": reason + } + email = template(old_owner, context) + email.send() diff --git a/taiga/projects/settings/__init__.py b/taiga/projects/settings/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/settings/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/settings/api.py b/taiga/projects/settings/api.py new file mode 100644 index 000000000..f39c93a2e --- /dev/null +++ b/taiga/projects/settings/api.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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(project__memberships__user=self.request.user)\ + .order_by('project__memberships__user_order') + + 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) diff --git a/taiga/projects/settings/choices.py b/taiga/projects/settings/choices.py new file mode 100644 index 000000000..1ece6f366 --- /dev/null +++ b/taiga/projects/settings/choices.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import enum +from django.utils.translation import gettext_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")), +) diff --git a/taiga/projects/settings/migrations/0001_initial.py b/taiga/projects/settings/migrations/0001_initial.py new file mode 100644 index 000000000..e20610037 --- /dev/null +++ b/taiga/projects/settings/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# 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')]), + ), + ] diff --git a/taiga/projects/settings/migrations/__init__.py b/taiga/projects/settings/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/settings/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/settings/models.py b/taiga/projects/settings/models.py new file mode 100644 index 000000000..4e92a17f5 --- /dev/null +++ b/taiga/projects/settings/models.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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", on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="user_project_settings", on_delete=models.CASCADE) + 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) diff --git a/taiga/projects/settings/permissions.py b/taiga/projects/settings/permissions.py new file mode 100644 index 000000000..634f1b788 --- /dev/null +++ b/taiga/projects/settings/permissions.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (TaigaResourcePermission, IsAuthenticated) + + +class UserProjectSettingsPermission(TaigaResourcePermission): + retrieve_perms = IsAuthenticated() + create_perms = IsAuthenticated() + update_perms = IsAuthenticated() + partial_update_perms = IsAuthenticated() + destroy_perms = IsAuthenticated() + list_perms = IsAuthenticated() diff --git a/taiga/projects/settings/serializers.py b/taiga/projects/settings/serializers.py new file mode 100644 index 000000000..ed0dc639a --- /dev/null +++ b/taiga/projects/settings/serializers.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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) diff --git a/taiga/projects/settings/services.py b/taiga/projects/settings/services.py new file mode 100644 index 000000000..42e23d015 --- /dev/null +++ b/taiga/projects/settings/services.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.db import IntegrityError +from django.utils.translation import gettext 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 diff --git a/taiga/projects/settings/utils.py b/taiga/projects/settings/utils.py new file mode 100644 index 000000000..93cdd2d09 --- /dev/null +++ b/taiga/projects/settings/utils.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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 diff --git a/taiga/projects/settings/validators.py b/taiga/projects/settings/validators.py new file mode 100644 index 000000000..c3698fa22 --- /dev/null +++ b/taiga/projects/settings/validators.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext 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 diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py new file mode 100644 index 000000000..610bc0661 --- /dev/null +++ b/taiga/projects/signals.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.conf import settings +from django.db.models import F +from django.dispatch import Signal + +from taiga.projects.notifications.services import create_notify_policy_if_not_exists + + +#################################### +# Signals over project items +#################################### + +## Membership + +def membership_post_delete(sender, instance, using, **kwargs): + instance.project.update_role_points() + + +def membership_post_save(sender, instance, using, **kwargs): + if not instance.user: + return + 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 + """ + if not created: + return + + if instance._importing: + return + + template = getattr(instance, "creation_template", None) + if template is None: + ProjectTemplate = apps.get_model("projects", "ProjectTemplate") + template = ProjectTemplate.objects.get(slug=settings.DEFAULT_PROJECT_TEMPLATE) + + if instance.tags: + template.tags = instance.tags + + if instance.tags_colors: + template.tags_colors = instance.tags_colors + + template.apply_to_project(instance) + + instance.save() + + Role = apps.get_model("users", "Role") + try: + owner_role = instance.roles.get(slug=template.default_owner_role) + except Role.DoesNotExist: + owner_role = instance.roles.first() + + if owner_role: + Membership = apps.get_model("projects", "Membership") + Membership.objects.create(user=instance.owner, project=instance, role=owner_role, + is_admin=True, email=instance.owner.email) + + +## swimlanes +def create_swimlane_user_story_statuses_on_swimalne_post_save(sender, instance, created, **kwargs): + """ + Populate new swimlanes with SwimlaneUserStoryStatus objects. + """ + if not created: + return + + if instance._importing: + return + + SwimlaneUserStoryStatus = apps.get_model("projects", "SwimlaneUserStoryStatus") + copy_from_main_status = instance.project.swimlanes.count() == 1 + objects = ( + SwimlaneUserStoryStatus( + swimlane=instance, + status=status, + wip_limit=status.wip_limit if copy_from_main_status else None + ) + for status in instance.project.us_statuses.all()) + + SwimlaneUserStoryStatus.objects.bulk_create(objects) + + +def set_default_project_swimlane_on_swimalne_post_save(sender, instance, created, **kwargs): + """ + Set as project.default_swimlane if is the only one created. + """ + if not created: + return + + if instance._importing: + return + + if instance.project.swimlanes.all().count() == 1: + instance.project.default_swimlane = instance + instance.project.save() + + +def create_swimlane_user_story_statuses_on_userstory_status_post_save(sender, instance, created, **kwargs): + """ + Populate swimlanes with SwimlaneUserStoryStatus objects when a new UserStoryStatus is created. + """ + if not created: + return + + SwimlaneUserStoryStatus = apps.get_model("projects", "SwimlaneUserStoryStatus") + copy_from_main_status = instance.project.swimlanes.count() == 1 + objects = ( + SwimlaneUserStoryStatus( + swimlane=swimlane, + status=instance, + wip_limit=instance.wip_limit if copy_from_main_status else None + ) + for swimlane in instance.project.swimlanes.all()) + + SwimlaneUserStoryStatus.objects.bulk_create(objects) + + +## US statuses + +def try_to_close_or_open_user_stories_when_edit_us_status(sender, instance, created, **kwargs): + from taiga.projects.userstories import services + + for user_story in instance.user_stories.all(): + if services.calculate_userstory_is_closed(user_story): + services.close_userstory(user_story) + else: + services.open_userstory(user_story) + + +## Task statuses + +def try_to_close_or_open_user_stories_when_edit_task_status(sender, instance, created, **kwargs): + from taiga.projects.userstories import services + + UserStory = apps.get_model("userstories", "UserStory") + + for user_story in UserStory.objects.filter(tasks__status=instance).distinct(): + if services.calculate_userstory_is_closed(user_story): + services.close_userstory(user_story) + else: + services.open_userstory(user_story) + + +## Custom signals + +issue_status_post_move_on_destroy = Signal() diff --git a/taiga/projects/tagging/__init__.py b/taiga/projects/tagging/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/tagging/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/tagging/api.py b/taiga/projects/tagging/api.py new file mode 100644 index 000000000..b3ad3f2ae --- /dev/null +++ b/taiga/projects/tagging/api.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import response +from taiga.base.decorators import detail_route +from taiga.base.utils.collections import OrderedSet + +from . import services +from . import validators + + +class TagsColorsResourceMixin: + @detail_route(methods=["GET"]) + def tags_colors(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "tags_colors", project) + + return response.Ok(dict(project.tags_colors)) + + @detail_route(methods=["POST"]) + def create_tag(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "create_tag", project) + self._raise_if_blocked(project) + + validator = validators.CreateTagValidator(data=request.DATA, project=project) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + services.create_tag(project, data.get("tag"), data.get("color")) + + return response.Ok() + + @detail_route(methods=["POST"]) + def edit_tag(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "edit_tag", project) + self._raise_if_blocked(project) + + validator = validators.EditTagTagValidator(data=request.DATA, project=project) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + services.edit_tag(project, + data.get("from_tag"), + to_tag=data.get("to_tag", None), + color=data.get("color", None)) + + return response.Ok() + + @detail_route(methods=["POST"]) + def delete_tag(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "delete_tag", project) + self._raise_if_blocked(project) + + validator = validators.DeleteTagValidator(data=request.DATA, project=project) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + services.delete_tag(project, data.get("tag")) + + return response.Ok() + + @detail_route(methods=["POST"]) + def mix_tags(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "mix_tags", project) + self._raise_if_blocked(project) + + validator = validators.MixTagsValidator(data=request.DATA, project=project) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + services.mix_tags(project, data.get("from_tags"), data.get("to_tag")) + + return response.Ok() + + +class TaggedResourceMixin: + def pre_save(self, obj): + if obj.tags: + self._pre_save_new_tags_in_project_tags_colors(obj) + super().pre_save(obj) + + def _pre_save_new_tags_in_project_tags_colors(self, obj): + new_obj_tags = OrderedSet() + new_tags_colors = {} + + for tag in obj.tags: + if isinstance(tag, (list, tuple)): + name, color = tag + name = name.lower() + + if color and not services.tag_exist_for_project_elements(obj.project, name): + new_tags_colors[name] = color + + new_obj_tags.add(name) + elif isinstance(tag, str): + new_obj_tags.add(tag.lower()) + + obj.tags = list(new_obj_tags) + + if new_tags_colors: + services.create_tags(obj.project, new_tags_colors) diff --git a/taiga/projects/tagging/fields.py b/taiga/projects/tagging/fields.py new file mode 100644 index 000000000..ca1a19262 --- /dev/null +++ b/taiga/projects/tagging/fields.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ + +from taiga.base.api import serializers +from taiga.base.exceptions import ValidationError + +import re + + +class TagsAndTagsColorsField(serializers.WritableField): + """ + Pickle objects serializer fior stories, tasks and issues tags. + """ + def __init__(self, *args, **kwargs): + def _validate_tag_field(value): + # Valid field: + # - ["tag1", "tag2", "tag3"...] + # - ["tag1", ["tag2", None], ["tag3", "#ccc"], [tag4, #cccccc]...] + for tag in value: + if isinstance(tag, str): + continue + + if isinstance(tag, (list, tuple)) and len(tag) == 2: + name = tag[0] + color = tag[1] + + if isinstance(name, str): + if color is None or color == "": + continue + + if isinstance(color, str) and re.match(r'^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): + continue + + raise ValidationError(_("Invalid tag '{value}'. The color is not a " + "valid HEX color or null.").format(value=tag)) + + raise ValidationError(_("Invalid tag '{value}'. it must be the name or a pair " + "'[\"name\", \"hex color/\" | null]'.").format(value=tag)) + + super().__init__(*args, **kwargs) + self.validators.append(_validate_tag_field) + + def to_native(self, obj): + return obj + + def from_native(self, data): + return data + + +class TagsField(serializers.WritableField): + """ + Pickle objects serializer for tags names. + """ + def __init__(self, *args, **kwargs): + def _validate_tag_field(value): + for tag in value: + if isinstance(tag, str): + continue + raise ValidationError(_("Invalid tag '{value}'. It must be the tag name.").format(value=tag)) + + super().__init__(*args, **kwargs) + self.validators.append(_validate_tag_field) + + def to_native(self, obj): + return obj + + def from_native(self, data): + return data + + +class TagsColorsField(serializers.WritableField): + """ + PgArray objects serializer. + """ + widget = widgets.Textarea + + def to_native(self, obj): + return dict(obj) + + def from_native(self, data): + return list(data.items()) diff --git a/taiga/projects/tagging/models.py b/taiga/projects/tagging/models.py new file mode 100644 index 000000000..e6d5f814e --- /dev/null +++ b/taiga/projects/tagging/models.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.contrib.postgres.fields import ArrayField +from django.utils.translation import gettext_lazy as _ + + +class TaggedMixin(models.Model): + tags = ArrayField(models.TextField(), + null=True, blank=True, default=list, verbose_name=_("tags")) + + class Meta: + abstract = True + + +class TagsColorsMixin(models.Model): + tags_colors = ArrayField(ArrayField(models.TextField(null=True, blank=True), size=2), + null=True, blank=True, default=list, verbose_name=_("tags colors")) + + class Meta: + abstract = True diff --git a/taiga/projects/tagging/serializers.py b/taiga/projects/tagging/serializers.py new file mode 100644 index 000000000..e07491659 --- /dev/null +++ b/taiga/projects/tagging/serializers.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import MethodField + + +class TaggedInProjectResourceSerializer(serializers.LightSerializer): + tags = MethodField() + + def get_tags(self, obj): + if not obj.tags: + return [] + + project_tag_colors = dict(obj.project.tags_colors) + return [[tag, project_tag_colors.get(tag, None)] for tag in obj.tags] diff --git a/taiga/projects/tagging/services.py b/taiga/projects/tagging/services.py new file mode 100644 index 000000000..aefb0a60e --- /dev/null +++ b/taiga/projects/tagging/services.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import connection + + +def tag_exist_for_project_elements(project, tag): + return tag in dict(project.tags_colors).keys() + + +def create_tags(project, new_tags_colors): + project.tags_colors += [[k.lower(), v] for k, v in new_tags_colors.items()] + project.save(update_fields=["tags_colors"]) + + +def create_tag(project, tag, color): + project.tags_colors.append([tag.lower(), color]) + project.save(update_fields=["tags_colors"]) + + +def edit_tag(project, from_tag, to_tag, color): + to_tag = to_tag.lower() + sql = """ + UPDATE userstories_userstory + SET tags = array_distinct(array_replace(tags, %(from_tag)s, %(to_tag)s)) + WHERE project_id = %(project_id)s AND %(from_tag)s = ANY(tags); + + UPDATE tasks_task + SET tags = array_distinct(array_replace(tags, %(from_tag)s, %(to_tag)s)) + WHERE project_id = %(project_id)s AND %(from_tag)s = ANY(tags); + + UPDATE issues_issue + SET tags = array_distinct(array_replace(tags, %(from_tag)s, %(to_tag)s)) + WHERE project_id = %(project_id)s AND %(from_tag)s = ANY(tags); + + UPDATE epics_epic + SET tags = array_distinct(array_replace(tags, %(from_tag)s, %(to_tag)s)) + WHERE project_id = %(project_id)s AND %(from_tag)s = ANY(tags); + """ + cursor = connection.cursor() + cursor.execute(sql, params={"from_tag": from_tag, "to_tag": to_tag, "project_id": project.id}) + + tags_colors = dict(project.tags_colors) + tags_colors.pop(from_tag) + tags_colors[to_tag] = color + project.tags_colors = list(tags_colors.items()) + project.save(update_fields=["tags_colors"]) + + +def rename_tag(project, from_tag, to_tag, **kwargs): + # Kwargs can have a color parameter + update_color = "color" in kwargs + to_tag = to_tag.lower() + if update_color: + color = kwargs.get("color") + else: + color = dict(project.tags_colors)[from_tag] + sql = """ + UPDATE userstories_userstory + SET tags = array_distinct(array_replace(tags, %(from_tag)s, %(to_tag)s)) + WHERE project_id = %(project_id)s AND %(from_tag)s = ANY(tags); + + UPDATE tasks_task + SET tags = array_distinct(array_replace(tags, %(from_tag)s, %(to_tag)s)) + WHERE project_id = %(project_id)s AND %(from_tag)s = ANY(tags); + + UPDATE issues_issue + SET tags = array_distinct(array_replace(tags, %(from_tag)s, %(to_tag)s)) + WHERE project_id = %(project_id)s AND %(from_tag)s = ANY(tags); + + UPDATE epics_epic + SET tags = array_distinct(array_replace(tags, %(from_tag)s, %(to_tag)s)) + WHERE project_id = %(project_id)s AND %(from_tag)s = ANY(tags); + """ + cursor = connection.cursor() + cursor.execute(sql, params={"from_tag": from_tag, "to_tag": to_tag, "project_id": project.id}) + + tags_colors = dict(project.tags_colors) + tags_colors.pop(from_tag) + tags_colors[to_tag] = color + project.tags_colors = list(tags_colors.items()) + project.save(update_fields=["tags_colors"]) + + +def delete_tag(project, tag): + sql = """ + UPDATE userstories_userstory + SET tags = array_remove(tags, %(tag)s) + WHERE project_id = %(project_id)s AND %(tag)s = ANY(tags); + + UPDATE tasks_task + SET tags = array_remove(tags, %(tag)s) + WHERE project_id = %(project_id)s AND %(tag)s = ANY(tags); + + UPDATE issues_issue + SET tags = array_remove(tags, %(tag)s) + WHERE project_id = %(project_id)s AND %(tag)s = ANY(tags); + + UPDATE epics_epic + SET tags = array_remove(tags, %(tag)s) + WHERE project_id = %(project_id)s AND %(tag)s = ANY(tags); + """ + cursor = connection.cursor() + cursor.execute(sql, params={"tag": tag, "project_id": project.id}) + + tags_colors = dict(project.tags_colors) + del tags_colors[tag] + project.tags_colors = list(tags_colors.items()) + project.save(update_fields=["tags_colors"]) + + +def mix_tags(project, from_tags, to_tag): + color = dict(project.tags_colors)[to_tag] + for from_tag in from_tags: + rename_tag(project, from_tag, to_tag, color=color) diff --git a/taiga/projects/tagging/signals.py b/taiga/projects/tagging/signals.py new file mode 100644 index 000000000..07db79e33 --- /dev/null +++ b/taiga/projects/tagging/signals.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +def tags_normalization(sender, instance, **kwargs): + if isinstance(instance.tags, (list, tuple)): + instance.tags = list(map(str.lower, instance.tags)) diff --git a/taiga/projects/tagging/validators.py b/taiga/projects/tagging/validators.py new file mode 100644 index 000000000..7666bf9eb --- /dev/null +++ b/taiga/projects/tagging/validators.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.exceptions import ValidationError + +from . import services +from . import fields + +import re + + +class ProjectTagValidator(validators.Validator): + def __init__(self, *args, **kwargs): + # Don't pass the extra project arg + self.project = kwargs.pop("project") + + # Instantiate the superclass normally + super().__init__(*args, **kwargs) + + +class CreateTagValidator(ProjectTagValidator): + tag = serializers.CharField() + color = serializers.CharField(required=False) + + def validate_tag(self, attrs, source): + tag = attrs.get(source, None) + if services.tag_exist_for_project_elements(self.project, tag): + raise ValidationError(_("This tag already exists.")) + + return attrs + + def validate_color(self, attrs, source): + color = attrs.get(source, None) + if color and not re.match(r'^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): + raise ValidationError(_("The color is not a valid HEX color.")) + + return attrs + + +class EditTagTagValidator(ProjectTagValidator): + from_tag = serializers.CharField() + to_tag = serializers.CharField(required=False) + color = serializers.CharField(required=False) + + def validate_from_tag(self, attrs, source): + tag = attrs.get(source, None) + if not services.tag_exist_for_project_elements(self.project, tag): + raise ValidationError(_("The tag doesn't exist.")) + + return attrs + + def validate_to_tag(self, attrs, source): + tag = attrs.get(source, None) + if services.tag_exist_for_project_elements(self.project, tag): + raise ValidationError(_("This tag already exists.")) + + return attrs + + def validate_color(self, attrs, source): + color = attrs.get(source, None) + if color and not re.match(r'^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): + raise ValidationError(_("The color is not a valid HEX color.")) + + return attrs + + def validate(self, data): + if "to_tag" not in data: + data["to_tag"] = data.get("from_tag") + + if "color" not in data: + data["color"] = dict(self.project.tags_colors).get(data.get("from_tag")) + + return data + + +class DeleteTagValidator(ProjectTagValidator): + tag = serializers.CharField() + + def validate_tag(self, attrs, source): + tag = attrs.get(source, None) + if not services.tag_exist_for_project_elements(self.project, tag): + raise ValidationError(_("The tag doesn't exist.")) + + return attrs + + +class MixTagsValidator(ProjectTagValidator): + from_tags = fields.TagsField() + to_tag = serializers.CharField() + + def validate_from_tags(self, attrs, source): + tags = attrs.get(source, None) + for tag in tags: + if not services.tag_exist_for_project_elements(self.project, tag): + raise ValidationError(_("The tag doesn't exist.")) + + return attrs + + def validate_to_tag(self, attrs, source): + tag = attrs.get(source, None) + if not services.tag_exist_for_project_elements(self.project, tag): + raise ValidationError(_("The tag doesn't exist.")) + + return attrs diff --git a/taiga/projects/tasks/__init__.py b/taiga/projects/tasks/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/tasks/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/tasks/admin.py b/taiga/projects/tasks/admin.py new file mode 100644 index 000000000..dfc82385b --- /dev/null +++ b/taiga/projects/tasks/admin.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin + +from taiga.projects.attachments.admin import AttachmentInline +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + +from . import models + + +class TaskAdmin(admin.ModelAdmin): + list_display = ["project", "milestone", "user_story", "ref", "subject",] + list_display_links = ["ref", "subject",] + inlines = [WatchedInline, VoteInline] + raw_id_fields = ["project"] + search_fields = ["subject", "description", "id", "ref"] + + def get_object(self, *args, **kwargs): + self.obj = super().get_object(*args, **kwargs) + return self.obj + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if (db_field.name in ["status", "milestone", "user_story"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter( + project=self.obj.project) + elif (db_field.name in ["owner", "assigned_to"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.obj.project) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + if (db_field.name in ["watchers"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related.parent_model.objects.filter( + memberships__project=self.obj.project) + return super().formfield_for_manytomany(db_field, request, **kwargs) + +admin.site.register(models.Task, TaskAdmin) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py new file mode 100644 index 000000000..c69034686 --- /dev/null +++ b/taiga/projects/tasks/api.py @@ -0,0 +1,337 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.http import HttpResponse +from django.utils.translation import gettext as _ + +from taiga.base.api.utils import get_object_or_error +from taiga.base import filters, response +from taiga.base import exceptions as exc +from taiga.base.decorators import list_route +from taiga.base.api import ModelCrudViewSet, ModelListViewSet +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.utils import json +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.mixins.promote import PromoteToUserStoryMixin +from taiga.projects.models import Project, TaskStatus +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 + +from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin + +from . import models +from . import permissions +from . import serializers +from . import services +from . import validators +from . import utils as tasks_utils + + +class TaskViewSet(AssignedToSignalMixin, OCCResourceMixin, VotedResourceMixin, + HistoryResourceMixin, WatchedResourceMixin, ByRefMixin, + TaggedResourceMixin, BlockedByProjectMixin, PromoteToUserStoryMixin, + ModelCrudViewSet): + validator_class = validators.TaskValidator + queryset = models.Task.objects.all() + permission_classes = (permissions.TaskPermission,) + filter_backends = (filters.CanViewTasksFilterBackend, + filters.RoleFilter, + filters.OwnersFilter, + filters.AssignedToFilter, + filters.StatusesFilter, + filters.TagsFilter, + filters.WatchersFilter, + filters.QFilter, + filters.CreatedDateFilter, + filters.ModifiedDateFilter, + filters.MilestoneEstimatedStartFilter, + filters.MilestoneEstimatedFinishFilter, + filters.FinishedDateFilter) + filter_fields = ["user_story", + "milestone", + "project", + "project__slug", + "status__is_closed"] + order_by_fields = ("project", + "milestone", + "status", + "created_date", + "modified_date", + "assigned_to", + "us_order", + "subject", + "total_voters") + + def get_serializer_class(self, *args, **kwargs): + if self.action in ["retrieve", "by_ref"]: + return serializers.TaskNeighborsSerializer + + if self.action == "list": + return serializers.TaskListSerializer + + return serializers.TaskSerializer + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.select_related("milestone", + "project", + "status", + "owner", + "assigned_to") + + include_attachments = "include_attachments" in self.request.QUERY_PARAMS + qs = tasks_utils.attach_extra_info(qs, user=self.request.user, + include_attachments=include_attachments) + + return qs + + def pre_conditions_on_save(self, obj): + super().pre_conditions_on_save(obj) + + if obj.milestone and obj.milestone.project != obj.project: + raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task.")) + + if obj.user_story and obj.user_story.project != obj.project: + raise exc.WrongArguments(_("You don't have permissions to set this user story to this task.")) + + if obj.status and obj.status.project != obj.project: + raise exc.WrongArguments(_("You don't have permissions to set this status to this task.")) + + if obj.milestone and obj.user_story and obj.milestone != obj.user_story.milestone: + raise exc.WrongArguments(_("You don't have permissions to set this sprint to this task.")) + + """ + Updating some attributes of the userstory can affect the ordering in the backlog, kanban or taskboard + These two methods generate a key for the task and can be used to be compared before and after + saving + If there is any difference it means an extra ordering update must be done + """ + def _us_order_key(self, obj): + return "{}-{}-{}".format(obj.project_id, obj.user_story_id, obj.us_order) + + def _taskboard_order_key(self, obj): + return "{}-{}-{}-{}".format(obj.project_id, obj.user_story_id, obj.status_id, obj.taskboard_order) + + def pre_save(self, obj): + if obj.user_story: + obj.milestone = obj.user_story.milestone + if not obj.id: + obj.owner = self.request.user + else: + self._old_us_order_key = self._us_order_key(self.get_object()) + self._old_taskboard_order_key = self._taskboard_order_key(self.get_object()) + + super().pre_save(obj) + + def _reorder_if_needed(self, obj, old_order_key, order_key, order_attr, + project, user_story=None, status=None, milestone=None): + # Executes the extra ordering if there is a difference in the ordering keys + if old_order_key != order_key: + extra_orders = json.loads(self.request.headers.get("set-orders", "{}")) + data = [{"task_id": obj.id, "order": getattr(obj, order_attr)}] + for id, order in extra_orders.items(): + data.append({"task_id": int(id), "order": order}) + + return services.update_tasks_order_in_bulk(data, + order_attr, + project, + user_story=user_story, + status=status, + milestone=milestone) + return {} + + def post_save(self, obj, created=False): + if not created: + # Let's reorder the related stuff after edit the element + orders_updated = {} + updated = self._reorder_if_needed(obj, + self._old_us_order_key, + self._us_order_key(obj), + "us_order", + obj.project, + user_story=obj.user_story) + orders_updated.update(updated) + updated = self._reorder_if_needed(obj, + self._old_taskboard_order_key, + self._taskboard_order_key(obj), + "taskboard_order", + obj.project, + user_story=obj.user_story, + status=obj.status, + milestone=obj.milestone) + orders_updated.update(updated) + self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated) + + super().post_save(obj, created) + + def update(self, request, *args, **kwargs): + self.object = self.get_object_or_none() + project_id = request.DATA.get('project', None) + + if project_id and self.object and self.object.project.id != project_id: + try: + new_project = Project.objects.get(pk=project_id) + self.check_permissions(request, "destroy", self.object) + self.check_permissions(request, "create", new_project) + + milestone_id = request.DATA.get('milestone', None) + if milestone_id is not None and new_project.milestones.filter(pk=milestone_id).count() == 0: + request.DATA['milestone'] = None + + us_id = request.DATA.get('user_story', None) + if us_id is not None and new_project.user_stories.filter(pk=us_id).count() == 0: + request.DATA['user_story'] = None + + status_id = request.DATA.get('status', None) + if status_id is not None: + try: + old_status = self.object.project.task_statuses.get(pk=status_id) + new_status = new_project.task_statuses.get(slug=old_status.slug) + request.DATA['status'] = new_status.id + except TaskStatus.DoesNotExist: + request.DATA['status'] = new_project.default_task_status.id + + except Project.DoesNotExist: + return response.BadRequest(_("The project doesn't exist")) + + return super().update(request, *args, **kwargs) + + @list_route(methods=["GET"]) + def filters_data(self, request, *args, **kwargs): + project_id = request.QUERY_PARAMS.get("project", None) + project = get_object_or_error(Project, request.user, id=project_id) + + filter_backends = self.get_filter_backends() + statuses_filter_backends = (f for f in filter_backends if f != filters.StatusesFilter) + assigned_to_filter_backends = (f for f in filter_backends if f != filters.AssignedToFilter) + owners_filter_backends = (f for f in filter_backends if f != filters.OwnersFilter) + roles_filter_backends = (f for f in filter_backends if f != filters.RoleFilter) + tags_filter_backends = (f for f in filter_backends if f != filters.TagsFilter) + + queryset = self.get_queryset() + querysets = { + "statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends), + "assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends), + "owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends), + "tags": self.filter_queryset(queryset, filter_backends=tags_filter_backends), + "roles": self.filter_queryset(queryset, filter_backends=roles_filter_backends), + } + return response.Ok(services.get_tasks_filters_data(project, querysets)) + + @list_route(methods=["GET"]) + def csv(self, request): + uuid = request.QUERY_PARAMS.get("uuid", None) + if uuid is None: + return response.NotFound() + + project = get_object_or_error(Project, request.user, tasks_csv_uuid=uuid) + queryset = project.tasks.all().order_by('ref') + data = services.tasks_to_csv(project, queryset) + csv_response = HttpResponse(data.getvalue(), content_type='application/csv; charset=utf-8') + csv_response['Content-Disposition'] = 'attachment; filename="tasks.csv"' + return csv_response + + @list_route(methods=["POST"]) + def bulk_create(self, request, **kwargs): + validator = validators.TasksBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + data = validator.data + project = Project.objects.get(id=data["project_id"]) + self.check_permissions(request, 'bulk_create', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + tasks = services.create_tasks_in_bulk( + data["bulk_tasks"], milestone_id=data["milestone_id"], user_story_id=data["us_id"], + status_id=data.get("status_id") or project.default_task_status_id, + project=project, owner=request.user, callback=self.post_save, precall=self.pre_save) + + tasks = self.get_queryset().filter(id__in=[i.id for i in tasks]) + for task in tasks: + self.persist_history_snapshot(obj=task) + + tasks_serialized = self.get_serializer_class()(tasks, many=True) + + 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_error(Project, request.user, pk=data["project_id"]) + milestone = get_object_or_error(Milestone, request.user, 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(): + return response.BadRequest(validator.errors) + + data = validator.data + project = get_object_or_error(Project, request.user, pk=data["project_id"]) + + self.check_permissions(request, "bulk_update_order", project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + user_story = None + user_story_id = data.get("user_story_id", None) + if user_story_id is not None: + user_story = get_object_or_error(UserStory, request.user, pk=user_story_id) + + status = None + status_id = data.get("status_id", None) + if status_id is not None: + status = get_object_or_error(TaskStatus, request.user, pk=status_id) + + milestone = None + milestone_id = data.get("milestone_id", None) + if milestone_id is not None: + milestone = get_object_or_error(Milestone, request.user, pk=milestone_id) + + ret = services.update_tasks_order_in_bulk(data["bulk_tasks"], + order_field, + project, + user_story=user_story, + status=status, + milestone=milestone) + return response.Ok(ret) + + @list_route(methods=["POST"]) + def bulk_update_taskboard_order(self, request, **kwargs): + return self._bulk_update_order("taskboard_order", request, **kwargs) + + @list_route(methods=["POST"]) + def bulk_update_us_order(self, request, **kwargs): + return self._bulk_update_order("us_order", request, **kwargs) + + +class TaskVotersViewSet(VotersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.TaskVotersPermission,) + resource_model = models.Task + + +class TaskWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.TaskWatchersPermission,) + resource_model = models.Task diff --git a/taiga/projects/tasks/apps.py b/taiga/projects/tasks/apps.py new file mode 100644 index 000000000..23d54fdab --- /dev/null +++ b/taiga/projects/tasks/apps.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.apps import AppConfig +from django.apps import apps +from django.db.models import signals + + +def connect_tasks_signals(): + from taiga.projects.tagging import signals as tagging_handlers + from . import signals as handlers + + # Finished date + signals.pre_save.connect(handlers.set_finished_date_when_edit_task, + sender=apps.get_model("tasks", "Task"), + dispatch_uid="set_finished_date_when_edit_task") + # Tags + signals.pre_save.connect(tagging_handlers.tags_normalization, + sender=apps.get_model("tasks", "Task"), + dispatch_uid="tags_normalization_task") + + +def connect_tasks_close_or_open_us_and_milestone_signals(): + from . import signals as handlers + # Cached prev object version + signals.pre_save.connect(handlers.cached_prev_task, + sender=apps.get_model("tasks", "Task"), + dispatch_uid="cached_prev_task") + # Open/Close US and Milestone + signals.post_save.connect(handlers.try_to_close_or_open_us_and_milestone_when_create_or_edit_task, + sender=apps.get_model("tasks", "Task"), + dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_task") + signals.post_delete.connect(handlers.try_to_close_or_open_us_and_milestone_when_delete_task, + sender=apps.get_model("tasks", "Task"), + dispatch_uid="try_to_close_or_open_us_and_milestone_when_delete_task") + + +def connect_tasks_custom_attributes_signals(): + from taiga.projects.custom_attributes import signals as custom_attributes_handlers + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_task, + sender=apps.get_model("tasks", "Task"), + dispatch_uid="create_custom_attribute_value_when_create_task") + + +def connect_all_tasks_signals(): + connect_tasks_signals() + connect_tasks_close_or_open_us_and_milestone_signals() + connect_tasks_custom_attributes_signals() + + +def disconnect_tasks_signals(): + signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="set_finished_date_when_edit_task") + signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="tags_normalization") + + +def disconnect_tasks_close_or_open_us_and_milestone_signals(): + signals.pre_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="cached_prev_task") + signals.post_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_task") + signals.post_delete.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="try_to_close_or_open_us_and_milestone_when_delete_task") + + +def disconnect_tasks_custom_attributes_signals(): + signals.post_save.disconnect(sender=apps.get_model("tasks", "Task"), + dispatch_uid="create_custom_attribute_value_when_create_task") + + +def disconnect_all_tasks_signals(): + disconnect_tasks_signals() + disconnect_tasks_close_or_open_us_and_milestone_signals() + disconnect_tasks_custom_attributes_signals() + + +class TasksAppConfig(AppConfig): + name = "taiga.projects.tasks" + verbose_name = "Tasks" + watched_types = ["tasks.task", ] + + def ready(self): + connect_all_tasks_signals() diff --git a/taiga/projects/tasks/migrations/0001_initial.py b/taiga/projects/tasks/migrations/0001_initial.py new file mode 100644 index 000000000..cd5b686e1 --- /dev/null +++ b/taiga/projects/tasks/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields +from django.conf import settings +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0002_auto_20140903_0920'), + ('milestones', '__first__'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('userstories', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Task', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=[], null=True, size=None, verbose_name='tags')), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('is_blocked', models.BooleanField(verbose_name='is blocked', default=False)), + ('blocked_note', models.TextField(blank=True, verbose_name='blocked note', default='')), + ('ref', models.BigIntegerField(null=True, blank=True, verbose_name='ref', db_index=True, default=None)), + ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('finished_date', models.DateTimeField(null=True, blank=True, verbose_name='finished date')), + ('subject', models.TextField(verbose_name='subject')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('is_iocaine', models.BooleanField(verbose_name='is iocaine', default=False)), + ('assigned_to', models.ForeignKey(null=True, verbose_name='assigned to', default=None, blank=True, to=settings.AUTH_USER_MODEL, related_name='tasks_assigned_to_me', on_delete=models.SET_NULL)), + ('milestone', models.ForeignKey(null=True, verbose_name='milestone', default=None, blank=True, to='milestones.Milestone', related_name='tasks', on_delete=models.SET_NULL)), + ('owner', models.ForeignKey(null=True, verbose_name='owner', default=None, blank=True, to=settings.AUTH_USER_MODEL, related_name='owned_tasks', on_delete=models.SET_NULL)), + ('project', models.ForeignKey(verbose_name='project', to='projects.Project', related_name='tasks', on_delete=models.CASCADE)), + ('status', models.ForeignKey(verbose_name='status', to='projects.TaskStatus', related_name='tasks', on_delete=models.SET_NULL)), + ('user_story', models.ForeignKey(null=True, verbose_name='user story', blank=True, to='userstories.UserStory', related_name='tasks', on_delete=models.SET_NULL)), + ('watchers', models.ManyToManyField(null=True, blank=True, to=settings.AUTH_USER_MODEL, verbose_name='watchers', related_name='tasks_task+')), + ], + options={ + 'verbose_name_plural': 'tasks', + 'ordering': ['project', 'created_date'], + 'verbose_name': 'task', + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/projects/tasks/migrations/0002_tasks_order_fields.py b/taiga/projects/tasks/migrations/0002_tasks_order_fields.py new file mode 100644 index 000000000..22894a949 --- /dev/null +++ b/taiga/projects/tasks/migrations/0002_tasks_order_fields.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='taskboard_order', + field=models.IntegerField(default=1, verbose_name='taskboard order'), + preserve_default=True, + ), + migrations.AddField( + model_name='task', + name='us_order', + field=models.IntegerField(default=1, verbose_name='us order'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/tasks/migrations/0003_task_external_reference.py b/taiga/projects/tasks/migrations/0003_task_external_reference.py new file mode 100644 index 000000000..70a6b082d --- /dev/null +++ b/taiga/projects/tasks/migrations/0003_task_external_reference.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0002_tasks_order_fields'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=False, null=False), blank=True, default=None, null=True, size=None, verbose_name='external reference'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/tasks/migrations/0004_auto_20141210_1107.py b/taiga/projects/tasks/migrations/0004_auto_20141210_1107.py new file mode 100644 index 000000000..d9c974890 --- /dev/null +++ b/taiga/projects/tasks/migrations/0004_auto_20141210_1107.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db import connection +from taiga.projects.userstories.models import * +from taiga.projects.tasks.models import * +from taiga.projects.issues.models import * +from taiga.projects.models import * + +def _fix_tags_model(tags_model): + table_name = tags_model._meta.db_table + query = "select id from (select id, unnest(tags) tag from %s) x where tag LIKE '%%,%%'"%(table_name) + cursor = connection.cursor() + cursor.execute(query) + for row in cursor.fetchall(): + id = row[0] + instance = tags_model.objects.get(id=id) + instance.tags = [tag.replace(",", "") for tag in instance.tags] + instance.save() + + +def fix_tags(apps, schema_editor): + _fix_tags_model(Task) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0003_task_external_reference'), + ] + + operations = [ + migrations.RunPython(fix_tags), + ] diff --git a/taiga/projects/tasks/migrations/0005_auto_20150114_0954.py b/taiga/projects/tasks/migrations/0005_auto_20150114_0954.py new file mode 100644 index 000000000..aadcbb730 --- /dev/null +++ b/taiga/projects/tasks/migrations/0005_auto_20150114_0954.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0004_auto_20141210_1107'), + ] + + operations = [ + migrations.AlterModelOptions( + name='task', + options={'ordering': ['project', 'created_date', 'ref'], 'verbose_name_plural': 'tasks', 'verbose_name': 'task'}, + ), + ] diff --git a/taiga/projects/tasks/migrations/0006_auto_20150623_1923.py b/taiga/projects/tasks/migrations/0006_auto_20150623_1923.py new file mode 100644 index 000000000..723e246bf --- /dev/null +++ b/taiga/projects/tasks/migrations/0006_auto_20150623_1923.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0005_auto_20150114_0954'), + ] + + operations = [ + migrations.AlterField( + model_name='task', + name='status', + field=models.ForeignKey(blank=True, null=True, to='projects.TaskStatus', related_name='tasks', verbose_name='status', on_delete=models.SET_NULL), + preserve_default=True, + ), + ] diff --git a/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py b/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py new file mode 100644 index 000000000..68f18a0bc --- /dev/null +++ b/taiga/projects/tasks/migrations/0007_auto_20150629_1556.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0006_auto_20150623_1923'), + ] + + operations = [ + migrations.AlterField( + model_name='task', + name='milestone', + field=models.ForeignKey(to='milestones.Milestone', related_name='tasks', default=None, verbose_name='milestone', on_delete=django.db.models.deletion.SET_NULL, null=True, blank=True), + preserve_default=True, + ), + ] diff --git a/taiga/projects/tasks/migrations/0008_remove_task_watchers.py b/taiga/projects/tasks/migrations/0008_remove_task_watchers.py new file mode 100644 index 000000000..64b3daad2 --- /dev/null +++ b/taiga/projects/tasks/migrations/0008_remove_task_watchers.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import connection +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from taiga.base.utils.contenttypes import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes(verbosity=0) + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT task_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM tasks_task_watchers INNER JOIN tasks_task ON tasks_task_watchers.task_id = tasks_task.id""".format(content_type_id=ContentType.objects.get(model='task').id) + cursor = connection.cursor() + cursor.execute(sql) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('tasks', '0007_auto_20150629_1556'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='task', + name='watchers', + ), + ] diff --git a/taiga/projects/tasks/migrations/0009_auto_20151104_1131.py b/taiga/projects/tasks/migrations/0009_auto_20151104_1131.py new file mode 100644 index 000000000..269803f1b --- /dev/null +++ b/taiga/projects/tasks/migrations/0009_auto_20151104_1131.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import connection, migrations, models + +def set_finished_date_for_tasks(apps, schema_editor): + # Updates the finished date from tasks according to the history_entries associated + # It takes the last history change updateing the status of a task and if it's a closed + # one it updates the finished_date attribute + sql=""" +WITH status_update AS( + WITH status_update AS( + WITH history_entries AS ( + SELECT + diff #>>'{status, 1}' new_status_id, + regexp_split_to_array(key, ':') as split_key, + created_at as date + FROM history_historyentry + WHERE diff #>>'{status, 1}' != '' + ) + SELECT + split_key[2] as object_id, + new_status_id::int, + MAX(date) as status_change_datetime + FROM history_entries + WHERE split_key[1] = 'tasks.task' + GROUP BY object_id, new_status_id, date + ) + SELECT status_update.* + FROM status_update + INNER JOIN projects_taskstatus + ON projects_taskstatus.id = new_status_id AND projects_taskstatus.is_closed = True +) +UPDATE tasks_task +SET finished_date = status_update.status_change_datetime +FROM status_update +WHERE tasks_task.id = status_update.object_id::int + """ + cursor = connection.cursor() + cursor.execute(sql) + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0008_remove_task_watchers'), + ] + + operations = [ + migrations.RunPython(set_finished_date_for_tasks), + ] diff --git a/taiga/projects/tasks/migrations/0010_auto_20160614_1201.py b/taiga/projects/tasks/migrations/0010_auto_20160614_1201.py new file mode 100644 index 000000000..880b57174 --- /dev/null +++ b/taiga/projects/tasks/migrations/0010_auto_20160614_1201.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0009_auto_20151104_1131'), + ] + + operations = [ + migrations.AlterField( + model_name='task', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=False, null=False), blank=True, default=None, null=True, size=None, verbose_name='external reference'), + ), + migrations.AlterField( + model_name='task', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=list, null=True, size=None, verbose_name='tags'), + ), + ] diff --git a/taiga/projects/tasks/migrations/0011_auto_20160928_0755.py b/taiga/projects/tasks/migrations/0011_auto_20160928_0755.py new file mode 100644 index 000000000..476acdfca --- /dev/null +++ b/taiga/projects/tasks/migrations/0011_auto_20160928_0755.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-09-28 07:55 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.base.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0010_auto_20160614_1201'), + ] + + operations = [ + migrations.AlterField( + model_name='task', + name='taskboard_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='taskboard order'), + ), + migrations.AlterField( + model_name='task', + name='us_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='us order'), + ), + ] diff --git a/taiga/projects/tasks/migrations/0012_add_due_date.py b/taiga/projects/tasks/migrations/0012_add_due_date.py new file mode 100644 index 000000000..9c30389e7 --- /dev/null +++ b/taiga/projects/tasks/migrations/0012_add_due_date.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.2 on 2018-04-09 09:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0011_auto_20160928_0755'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='due_date', + field=models.DateField(blank=True, default=None, null=True, verbose_name='due date'), + ), + migrations.AddField( + model_name='task', + name='due_date_reason', + field=models.TextField(blank=True, default='', verbose_name='reason for the due date'), + ), + ] diff --git a/taiga/projects/tasks/migrations/0013_auto_20200615_0811.py b/taiga/projects/tasks/migrations/0013_auto_20200615_0811.py new file mode 100644 index 000000000..1ed09a851 --- /dev/null +++ b/taiga/projects/tasks/migrations/0013_auto_20200615_0811.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-15 08:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0012_add_due_date'), + ] + + operations = [ + migrations.AlterField( + model_name='task', + name='is_blocked', + field=models.BooleanField(blank=True, default=False, verbose_name='is blocked'), + ), + migrations.AlterField( + model_name='task', + name='is_iocaine', + field=models.BooleanField(blank=True, default=False, verbose_name='is iocaine'), + ), + migrations.AlterField( + model_name='task', + name='user_story', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='userstories.UserStory', verbose_name='user story'), + ), + ] diff --git a/taiga/projects/tasks/migrations/__init__.py b/taiga/projects/tasks/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/tasks/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/tasks/models.py b/taiga/projects/tasks/models.py new file mode 100644 index 000000000..68ceb3a10 --- /dev/null +++ b/taiga/projects/tasks/models.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField +from django.conf import settings +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from taiga.base.utils.time import timestamp_ms +from taiga.projects.due_dates.models import DueDateMixin +from taiga.projects.occ import OCCModelMixin +from taiga.projects.notifications.mixins import WatchedModelMixin +from taiga.projects.mixins.blocked import BlockedMixin +from taiga.projects.tagging.models import TaggedMixin + + +class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, DueDateMixin, models.Model): + user_story = models.ForeignKey( + "userstories.UserStory", + null=True, + blank=True, + related_name="tasks", + verbose_name=_("user story"), + on_delete=models.CASCADE, + ) + ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, + verbose_name=_("ref")) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + default=None, + related_name="owned_tasks", + verbose_name=_("owner"), + on_delete=models.SET_NULL, + ) + status = models.ForeignKey( + "projects.TaskStatus", + null=True, + blank=True, + related_name="tasks", + verbose_name=_("status"), + on_delete=models.SET_NULL, + ) + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="tasks", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + milestone = models.ForeignKey( + "milestones.Milestone", + null=True, + blank=True, + on_delete=models.SET_NULL, + default=None, + related_name="tasks", + verbose_name=_("milestone") + ) + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + finished_date = models.DateTimeField(null=True, blank=True, + verbose_name=_("finished date")) + subject = models.TextField(null=False, blank=False, + verbose_name=_("subject")) + + us_order = models.BigIntegerField(null=False, blank=False, default=timestamp_ms, + verbose_name=_("us order")) + taskboard_order = models.BigIntegerField(null=False, blank=False, default=timestamp_ms, + verbose_name=_("taskboard order")) + + description = models.TextField(null=False, blank=True, verbose_name=_("description")) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=True, + null=True, + default=None, + related_name="tasks_assigned_to_me", + verbose_name=_("assigned to"), + on_delete=models.SET_NULL, + ) + attachments = GenericRelation("attachments.Attachment") + is_iocaine = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is iocaine")) + external_reference = ArrayField(models.TextField(null=False, blank=False), + null=True, blank=True, default=None, verbose_name=_("external reference")) + _importing = None + + class Meta: + verbose_name = "task" + verbose_name_plural = "tasks" + ordering = ["project", "created_date", "ref"] + # unique_together = ("ref", "project") + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + if not self.status: + self.status = self.project.default_task_status + + return super().save(*args, **kwargs) + + 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 diff --git a/taiga/projects/tasks/permissions.py b/taiga/projects/tasks/permissions.py new file mode 100644 index 000000000..29a0a5d12 --- /dev/null +++ b/taiga/projects/tasks/permissions.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsAuthenticated, IsSuperUser +from taiga.permissions.permissions import HasProjectPerm, IsProjectAdmin + +from taiga.permissions.permissions import CommentAndOrUpdatePerm + + +class TaskPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_tasks') + create_perms = HasProjectPerm('add_task') + update_perms = CommentAndOrUpdatePerm('modify_task', 'comment_task') + partial_update_perms = CommentAndOrUpdatePerm('modify_task', 'comment_task') + destroy_perms = HasProjectPerm('delete_task') + list_perms = AllowAny() + filters_data_perms = AllowAny() + 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') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_tasks') + promote_to_us_perms = IsAuthenticated() & HasProjectPerm('view_tasks') + + +class TaskVotersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_tasks') + list_perms = HasProjectPerm('view_tasks') + + +class TaskWatchersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_tasks') + list_perms = HasProjectPerm('view_tasks') diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py new file mode 100644 index 000000000..6c3679849 --- /dev/null +++ b/taiga/projects/tasks/serializers.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.base.neighbors import NeighborsSerializerMixin + +from taiga.mdrender.service import render as mdrender +from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.due_dates.serializers import DueDateSerializerMixin +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer +from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin +from taiga.projects.history.mixins import TotalCommentsSerializerMixin + + +class TaskListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, + OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, ProjectExtraInfoSerializerMixin, + BasicAttachmentsInfoSerializerMixin, TaggedInProjectResourceSerializer, + TotalCommentsSerializerMixin, DueDateSerializerMixin, + serializers.LightSerializer): + + id = Field() + user_story = Field(attr="user_story_id") + ref = Field() + project = Field(attr="project_id") + milestone = Field(attr="milestone_id") + milestone_slug = MethodField() + created_date = Field() + modified_date = Field() + finished_date = Field() + subject = Field() + us_order = Field() + taskboard_order = Field() + is_iocaine = Field() + external_reference = Field() + version = Field() + watchers = Field() + is_blocked = Field() + blocked_note = Field() + is_closed = MethodField() + user_story_extra_info = Field() + + def get_generated_user_stories(self, obj): + assert hasattr(obj, "generated_user_stories_attr"),\ + "instance must have a generated_user_stories_attr attribute" + return obj.generated_user_stories_attr + + def get_milestone_slug(self, obj): + return obj.milestone.slug if obj.milestone else None + + def get_is_closed(self, obj): + return obj.status is not None and obj.status.is_closed + + +class TaskSerializer(TaskListSerializer): + comment = MethodField() + generated_user_stories = MethodField() + blocked_note_html = MethodField() + description = Field() + description_html = MethodField() + + def get_comment(self, obj): + return "" + + def get_blocked_note_html(self, obj): + return mdrender(obj.project, obj.blocked_note) + + def get_description_html(self, obj): + return mdrender(obj.project, obj.description) + + +class TaskNeighborsSerializer(NeighborsSerializerMixin, TaskSerializer): + pass diff --git a/taiga/projects/tasks/services.py b/taiga/projects/tasks/services.py new file mode 100644 index 000000000..9f097fe88 --- /dev/null +++ b/taiga/projects/tasks/services.py @@ -0,0 +1,483 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import csv +import io +import logging + +from collections import OrderedDict +from operator import itemgetter +from contextlib import closing + +from django.core.exceptions import ObjectDoesNotExist +from django.db import connection +from django.utils.translation import gettext as _ + +from taiga.base.utils import db, text +from taiga.projects.history.services import take_snapshot +from taiga.projects.services import apply_order_updates +from taiga.projects.tasks.apps import connect_tasks_signals +from taiga.projects.tasks.apps import disconnect_tasks_signals +from taiga.events import events +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.notifications.utils import attach_watchers_to_queryset + +from . import models + + +logger = logging.getLogger(__name__) + + +##################################################### +# Bulk actions +##################################################### + +def get_tasks_from_bulk(bulk_data, **additional_fields): + """Convert `bulk_data` into a list of tasks. + + :param bulk_data: List of tasks in bulk format. + :param additional_fields: Additional fields when instantiating each task. + + :return: List of `Task` instances. + """ + return [models.Task(subject=line, **additional_fields) + for line in text.split_in_lines(bulk_data)] + + +def create_tasks_in_bulk(bulk_data, callback=None, precall=None, **additional_fields): + """Create tasks from `bulk_data`. + + :param bulk_data: List of tasks in bulk format. + :param callback: Callback to execute after each task save. + :param additional_fields: Additional fields when instantiating each task. + + :return: List of created `Task` instances. + """ + tasks = get_tasks_from_bulk(bulk_data, **additional_fields) + + disconnect_tasks_signals() + + try: + db.save_in_bulk(tasks, callback, precall) + finally: + connect_tasks_signals() + + return tasks + + +def update_tasks_order_in_bulk(bulk_data: list, field: str, project: object, + user_story: object=None, status: object=None, milestone: object=None): + """ + Updates the order of the tasks specified adding the extra updates needed + to keep consistency. + + [{'task_id': , 'order': }, ...] + """ + tasks = project.tasks.all() + if user_story is not None: + tasks = tasks.filter(user_story=user_story) + if status is not None: + tasks = tasks.filter(status=status) + if milestone is not None: + tasks = tasks.filter(milestone=milestone) + + task_orders = {task.id: getattr(task, field) for task in tasks} + new_task_orders = {e["task_id"]: e["order"] for e in bulk_data} + apply_order_updates(task_orders, new_task_orders) + + task_ids = task_orders.keys() + events.emit_event_for_ids(ids=task_ids, + content_type="tasks.task", + projectid=project.pk) + + db.update_attr_in_bulk_for_ids(task_orders, field, models.Task) + return task_orders + + +def snapshot_tasks_in_bulk(bulk_data, user): + for task_data in bulk_data: + try: + task = models.Task.objects.get(pk=task_data['task_id']) + take_snapshot(task, user=user) + except models.Task.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': , 'order': }, ...] + """ + 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 +##################################################### + +def tasks_to_csv(project, queryset): + csv_data = io.StringIO() + fieldnames = ["id", "ref", "subject", "description", "user_story", "sprint_id", + "sprint", "sprint_estimated_start", "sprint_estimated_finish", + "owner", "owner_full_name", + "assigned_to", + "assigned_to_full_name", "status", "is_iocaine", "is_closed", + "us_order", + "taskboard_order", "attachments", "external_reference", + "tags", "watchers", + "voters", "created_date", "modified_date", "finished_date", + "due_date", + "due_date_reason"] + + custom_attrs = project.taskcustomattributes.all() + for custom_attr in custom_attrs: + fieldnames.append(custom_attr.name) + + queryset = queryset.prefetch_related("attachments", + "generated_user_stories", + "custom_attributes_values") + queryset = queryset.select_related("milestone", + "owner", + "assigned_to", + "status", + "project", + "user_story") + + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + + writer = csv.DictWriter(csv_data, fieldnames=fieldnames) + writer.writeheader() + for task in queryset: + task_data = { + "id": task.id, + "ref": task.ref, + "subject": task.subject, + "description": task.description, + "user_story": task.user_story.ref if task.user_story else None, + "sprint_id": task.milestone.id if task.milestone else None, + "sprint": task.milestone.name if task.milestone else None, + "sprint_estimated_start": task.milestone.estimated_start if task.milestone else None, + "sprint_estimated_finish": task.milestone.estimated_finish if task.milestone else None, + "owner": task.owner.username if task.owner else None, + "owner_full_name": task.owner.get_full_name() if task.owner else None, + "assigned_to": task.assigned_to.username if task.assigned_to else None, + "assigned_to_full_name": task.assigned_to.get_full_name() if task.assigned_to else None, + "status": task.status.name if task.status else None, + "is_iocaine": task.is_iocaine, + "is_closed": task.status is not None and task.status.is_closed, + "us_order": task.us_order, + "taskboard_order": task.taskboard_order, + "attachments": task.attachments.count(), + "external_reference": task.external_reference, + "tags": ",".join(task.tags or []), + "watchers": task.watchers, + "voters": task.total_voters, + "created_date": task.created_date, + "modified_date": task.modified_date, + "finished_date": task.finished_date, + "due_date": task.due_date, + "due_date_reason": task.due_date_reason, + } + for custom_attr in custom_attrs: + if not hasattr(task, "custom_attributes_values"): + continue + value = task.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) + task_data[custom_attr.name] = value + writer.writerow(task_data) + + return csv_data + + +##################################################### +# Api filter data +##################################################### + +def _get_tasks_statuses(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + SELECT "projects_taskstatus"."id", + "projects_taskstatus"."name", + "projects_taskstatus"."color", + "projects_taskstatus"."order", + (SELECT count(*) + FROM "tasks_task" + INNER JOIN "projects_project" ON + ("tasks_task"."project_id" = "projects_project"."id") + WHERE {where} AND "tasks_task"."status_id" = "projects_taskstatus"."id") + FROM "projects_taskstatus" + WHERE "projects_taskstatus"."project_id" = %s + ORDER BY "projects_taskstatus"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, color, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": color, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_tasks_assigned_to(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT assigned_to_id, count(assigned_to_id) count + FROM "tasks_task" + INNER JOIN "projects_project" ON ("tasks_task"."project_id" = "projects_project"."id") + WHERE {where} AND "tasks_task"."assigned_to_id" IS NOT NULL + GROUP BY assigned_to_id + ) + + SELECT "projects_membership"."user_id" user_id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."assigned_to_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- unassigned tasks + UNION + + SELECT NULL user_id, NULL, NULL, count(coalesce(assigned_to_id, -1)) count + FROM "tasks_task" + INNER JOIN "projects_project" ON ("tasks_task"."project_id" = "projects_project"."id") + WHERE {where} AND "tasks_task"."assigned_to_id" IS NULL + GROUP BY assigned_to_id + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id] + where_params) + rows = cursor.fetchall() + + result = [] + none_valued_added = False + for id, full_name, username, count in rows: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + + if id is None: + none_valued_added = True + + # If there was no task with null assigned_to we manually add it + if not none_valued_added: + result.append({ + "id": None, + "full_name": "", + "count": 0, + }) + + return sorted(result, key=itemgetter("full_name")) + + +def _get_tasks_roles(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH "task_counters" AS ( + SELECT DISTINCT "tasks_task"."status_id" "status_id", + "tasks_task"."id" "us_id", + "projects_membership"."role_id" "role_id" + FROM "tasks_task" + INNER JOIN "projects_project" + ON ("tasks_task"."project_id" = "projects_project"."id") + LEFT OUTER JOIN "projects_membership" + ON "projects_membership"."user_id" = "tasks_task"."assigned_to_id" + WHERE {where} + ), + "counters" AS ( + SELECT "role_id" as "role_id", + COUNT("role_id") "count" + FROM "task_counters" + GROUP BY "role_id" + ) + + SELECT "users_role"."id", + "users_role"."name", + "users_role"."order", + COALESCE("counters"."count", 0) + FROM "users_role" + LEFT OUTER JOIN "counters" + ON "counters"."role_id" = "users_role"."id" + WHERE "users_role"."project_id" = %s + ORDER BY "users_role"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": None, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_tasks_owners(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH counters AS ( + SELECT "tasks_task"."owner_id" owner_id, + count(coalesce("tasks_task"."owner_id", -1)) count + FROM "tasks_task" + INNER JOIN "projects_project" ON ("tasks_task"."project_id" = "projects_project"."id") + WHERE {where} + GROUP BY "tasks_task"."owner_id" + ) + + SELECT "projects_membership"."user_id" id, + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) count + FROM projects_membership + LEFT OUTER JOIN counters ON ("projects_membership"."user_id" = "counters"."owner_id") + INNER JOIN "users_user" ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- System users + UNION + + SELECT "users_user"."id" user_id, + "users_user"."full_name" full_name, + "users_user"."username" username, + COALESCE("counters".count, 0) count + FROM users_user + LEFT OUTER JOIN counters ON ("users_user"."id" = "counters"."owner_id") + WHERE ("users_user"."is_system" IS TRUE) + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, full_name, username, count in rows: + if count > 0: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + return sorted(result, key=itemgetter("full_name")) + + +def _get_tasks_tags(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH tasks_tags AS ( + SELECT tag, + COUNT(tag) counter FROM ( + SELECT UNNEST(tasks_task.tags) tag + FROM tasks_task + INNER JOIN projects_project + ON (tasks_task.project_id = projects_project.id) + WHERE {where}) tags + GROUP BY tag), + project_tags AS ( + SELECT reduce_dim(tags_colors) tag_color + FROM projects_project + WHERE id=%s) + + SELECT tag_color[1] tag, + tag_color[2] color, + COALESCE(tasks_tags.counter, 0) counter + FROM project_tags + LEFT JOIN tasks_tags ON project_tags.tag_color[1] = tasks_tags.tag + ORDER BY tag + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for name, color, count in rows: + result.append({ + "name": name, + "color": color, + "count": count, + }) + return sorted(result, key=itemgetter("name")) + + +def get_tasks_filters_data(project, querysets): + """ + Given a project and an tasks queryset, return a simple data structure + of all possible filters for the tasks in the queryset. + """ + data = OrderedDict([ + ("statuses", _get_tasks_statuses(project, querysets["statuses"])), + ("assigned_to", _get_tasks_assigned_to(project, querysets["assigned_to"])), + ("owners", _get_tasks_owners(project, querysets["owners"])), + ("tags", _get_tasks_tags(project, querysets["tags"])), + ("roles", _get_tasks_roles(project, querysets["roles"])), + ]) + + return data diff --git a/taiga/projects/tasks/signals.py b/taiga/projects/tasks/signals.py new file mode 100644 index 000000000..431480e46 --- /dev/null +++ b/taiga/projects/tasks/signals.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +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_task(sender, instance, **kwargs): + instance.prev = None + if instance.id: + instance.prev = sender.objects.get(id=instance.id) + + +###################################### +# 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) + _try_to_close_or_open_milestone_when_create_or_edit_task(instance) + + +def try_to_close_or_open_us_and_milestone_when_delete_task(sender, instance, **kwargs): + _try_to_close_or_open_us_when_delete_task(instance) + _try_to_close_milestone_when_delete_task(instance) + + +# US +def _try_to_close_or_open_us_when_create_or_edit_task(instance): + from taiga.projects.userstories import services as us_service + + if instance.user_story_id: + if us_service.calculate_userstory_is_closed(instance.user_story): + us_service.close_userstory(instance.user_story) + else: + us_service.open_userstory(instance.user_story) + + if instance.prev and instance.prev.user_story_id and instance.prev.user_story_id != instance.user_story_id: + if us_service.calculate_userstory_is_closed(instance.prev.user_story): + us_service.close_userstory(instance.prev.user_story) + else: + us_service.open_userstory(instance.prev.user_story) + + +def _try_to_close_or_open_us_when_delete_task(instance): + from taiga.projects.userstories import services as us_service + + with suppress(ObjectDoesNotExist): + if instance.user_story_id: + if us_service.calculate_userstory_is_closed(instance.user_story): + us_service.close_userstory(instance.user_story) + else: + us_service.open_userstory(instance.user_story) + + +# Milestone +def _try_to_close_or_open_milestone_when_create_or_edit_task(instance): + 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_task(instance): + from taiga.projects.milestones import services + + with suppress(ObjectDoesNotExist): + if instance.milestone_id and services.calculate_milestone_is_closed(instance.milestone): + services.close_milestone(instance.milestone) + + +#################################### +# Signals for set finished date +#################################### + +def set_finished_date_when_edit_task(sender, instance, **kwargs): + if instance.status is None: + return + if instance.status.is_closed and not instance.finished_date: + instance.finished_date = timezone.now() + elif not instance.status.is_closed and instance.finished_date: + instance.finished_date = None diff --git a/taiga/projects/tasks/utils.py b/taiga/projects/tasks/utils.py new file mode 100644 index 000000000..d4b4ec8ff --- /dev/null +++ b/taiga/projects/tasks/utils.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.projects.attachments.utils import attach_basic_attachments +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset +from taiga.projects.history.utils import attach_total_comments_to_queryset + + +def attach_user_story_extra_info(queryset, as_field="user_story_extra_info"): + """Attach userstory extra info as json column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach the userstory extra info as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + sql = """SELECT row_to_json(u) + FROM (SELECT "userstories_userstory"."id" AS "id", + "userstories_userstory"."ref" AS "ref", + "userstories_userstory"."subject" AS "subject", + (SELECT json_agg(row_to_json(t)) + FROM (SELECT "epics_epic"."id" AS "id", + "epics_epic"."ref" AS "ref", + "epics_epic"."subject" AS "subject", + "epics_epic"."color" AS "color", + (SELECT row_to_json(p) + FROM (SELECT "projects_project"."id" AS "id", + "projects_project"."name" AS "name", + "projects_project"."slug" AS "slug" + ) p + ) AS "project" + FROM "epics_relateduserstory" + INNER JOIN "epics_epic" + ON "epics_epic"."id" = "epics_relateduserstory"."epic_id" + INNER JOIN "projects_project" + ON "projects_project"."id" = "epics_epic"."project_id" + WHERE "epics_relateduserstory"."user_story_id" = "{tbl}"."user_story_id" + ORDER BY "projects_project"."name", "epics_epic"."ref") t) AS "epics" + FROM "userstories_userstory" + WHERE "userstories_userstory"."id" = "{tbl}"."user_story_id") u""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_generated_user_stories(queryset, as_field="generated_user_stories_attr"): + """Attach generated user stories json column to each object of the queryset. + + :param queryset: A Django issues queryset object. + :param as_field: Attach the generated user stories as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(t)) + FROM( + SELECT + userstories_userstory.id, + userstories_userstory.ref, + userstories_userstory.subject + FROM userstories_userstory + WHERE generated_from_task_id = {tbl}.id) t""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False): + if include_attachments: + queryset = attach_basic_attachments(queryset) + queryset = queryset.extra(select={"include_attachments": "True"}) + + queryset = attach_generated_user_stories(queryset) + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + queryset = attach_user_story_extra_info(queryset) + queryset = attach_total_comments_to_queryset(queryset) + return queryset diff --git a/taiga/projects/tasks/validators.py b/taiga/projects/tasks/validators.py new file mode 100644 index 000000000..b6bf119f2 --- /dev/null +++ b/taiga/projects/tasks/validators.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext 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.models import TaskStatus +from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer +from taiga.projects.notifications.validators import WatchersValidator +from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.userstories.models import UserStory +from taiga.projects.validators import ProjectExistsValidator + +from . import models + + +class TaskValidator(AssignedToValidator, WatchersValidator, EditableWatchedResourceSerializer, + validators.ModelValidator): + tags = TagsAndTagsColorsField(default=[], required=False) + external_reference = PgArrayField(required=False) + + class Meta: + model = models.Task + read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') + + +class TasksBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + milestone_id = serializers.IntegerField() + status_id = serializers.IntegerField(required=False) + us_id = serializers.IntegerField(required=False) + bulk_tasks = serializers.CharField() + + 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(_("Invalid milestone id.")) + + return attrs + + def validate_status_id(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + + if not TaskStatus.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid task status id.")) + + return attrs + + def validate_us_id(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + + if "milestone_id" in attrs: + filters["milestone__id"] = attrs["milestone_id"] + + filters["id"] = attrs["us_id"] + + if not UserStory.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story id.")) + + return attrs + + +# Order bulk validators + +class _TaskOrderBulkValidator(validators.Validator): + task_id = serializers.IntegerField() + order = serializers.IntegerField() + + +class UpdateTasksOrderBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + status_id = serializers.IntegerField(required=False) + us_id = serializers.IntegerField(required=False) + milestone_id = serializers.IntegerField(required=False) + bulk_tasks = _TaskOrderBulkValidator(many=True) + + def validate_status_id(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + filters["id"] = attrs[source] + + if not TaskStatus.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid task status id. The status must belong to " + "the same project.")) + + return attrs + + def validate_us_id(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + + if "milestone_id" in attrs: + filters["milestone__id"] = attrs["milestone_id"] + + filters["id"] = attrs[source] + + if not UserStory.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story id. The user story must belong to " + "the same project.")) + + return attrs + + 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(_("Invalid milestone id. The milestone must belong to " + "the same project.")) + + return attrs + + def validate_bulk_tasks(self, attrs, source): + filters = {"project__id": attrs["project_id"]} + if "status_id" in attrs: + filters["status__id"] = attrs["status_id"] + if "us_id" in attrs: + filters["user_story__id"] = attrs["us_id"] + if "milestone_id" in attrs: + filters["milestone__id"] = attrs["milestone_id"] + + filters["id__in"] = [t["task_id"] for t in attrs[source]] + + if models.Task.objects.filter(**filters).count() != len(filters["id__in"]): + raise ValidationError(_("Invalid task ids. All tasks must belong to the same project and, " + "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 diff --git a/taiga/projects/templates/emails/membership_invitation-body-html.jinja b/taiga/projects/templates/emails/membership_invitation-body-html.jinja new file mode 100644 index 000000000..fdf8d70eb --- /dev/null +++ b/taiga/projects/templates/emails/membership_invitation-body-html.jinja @@ -0,0 +1,36 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/hero-body-html.jinja" %} + +{% if membership.invited_by %} + {% set sender_full_name=membership.invited_by.get_full_name() %} +{% else %} + {% set sender_full_name=_("someone") %} +{% endif %} + + +{% block body %} + {% trans full_name=sender_full_name, project=membership.project, product_name=sr("product_name") %} +

You have been invited to {{ product_name }}!

+

Hi! {{ full_name }} has sent you an invitation to join project {{ project }} in {{ product_name }}.
Taiga is an Open Source Agile Project Management Tool. There is no cost for you to be a Taiga user.

+ {% endtrans %} + + {% if membership.invitation_extra_text %} + {% trans extra=membership.invitation_extra_text|linebreaksbr %} +

And now a few words from the jolly good fellow or sistren
who thought so kindly as to invite you

+

{{ extra }}

+ {% endtrans %} + {% endif %} + + {{ _("Accept your invitation") }} + {% trans signature=sr("signature") %} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/templates/emails/membership_invitation-body-text.jinja b/taiga/projects/templates/emails/membership_invitation-body-text.jinja new file mode 100644 index 000000000..9dda766d3 --- /dev/null +++ b/taiga/projects/templates/emails/membership_invitation-body-text.jinja @@ -0,0 +1,34 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% if membership.invited_by %} + {% set sender_full_name=membership.invited_by.get_full_name() %} +{% else %} + {% set sender_full_name=_("someone") %} +{% endif %} +{% trans full_name=sender_full_name, project=membership.project, product_name=sr("product_name") %} +You, or someone you know, has invited you to {{ product_name }} + +Hi! {{ full_name }} has sent you an invitation to join a project called {{ project }} in {{ product_name }}. +Taiga is an Open Source Agile Project Management Tool. There is no cost for you to be a Taiga user. + +{% endtrans %} +{% if membership.invitation_extra_text %} + {% trans extra=membership.invitation_extra_text %} +And now a few words from the jolly good fellow or sistren who thought so kindly as to invite you: + +{{ extra }} + {% endtrans %} +{% endif %} +{{ _("Accept your invitation to Taiga following this link:") }} +{{ resolve_front_url("invitation", membership.token) }} + +{% trans signature=sr("signature") %} +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/templates/emails/membership_invitation-subject.jinja b/taiga/projects/templates/emails/membership_invitation-subject.jinja new file mode 100644 index 000000000..2fdc3e9f2 --- /dev/null +++ b/taiga/projects/templates/emails/membership_invitation-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=membership.project|safe %} +[Taiga] Invitation to join to the project '{{ project }}' +{% endtrans %} diff --git a/taiga/projects/templates/emails/membership_notification-body-html.jinja b/taiga/projects/templates/emails/membership_notification-body-html.jinja new file mode 100644 index 000000000..0afff0648 --- /dev/null +++ b/taiga/projects/templates/emails/membership_notification-body-html.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), url=resolve_front_url("project", membership.project.slug), full_name=membership.user.get_full_name(), project=membership.project %} +

You have been added to a project

+

Hello {{ full_name }},
you have been added to the project {{ project }}

+ Go to project +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/templates/emails/membership_notification-body-text.jinja b/taiga/projects/templates/emails/membership_notification-body-text.jinja new file mode 100644 index 000000000..d3fca4fad --- /dev/null +++ b/taiga/projects/templates/emails/membership_notification-body-text.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"),url=resolve_front_url("project", membership.project.slug), full_name=membership.user.get_full_name(), project=membership.project %} +You have been added to a project + +Hello {{ full_name }},you have been added to the project {{ project }} + +See project at {{ url }} + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/templates/emails/membership_notification-subject.jinja b/taiga/projects/templates/emails/membership_notification-subject.jinja new file mode 100644 index 000000000..c4115a14e --- /dev/null +++ b/taiga/projects/templates/emails/membership_notification-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=membership.project|safe %} +[Taiga] Added to the project '{{ project }}' +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_accept-body-html.jinja b/taiga/projects/templates/emails/transfer_accept-body-html.jinja new file mode 100644 index 000000000..95f5afa04 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_accept-body-html.jinja @@ -0,0 +1,29 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans old_owner_name=old_owner.get_full_name(), new_owner_name=new_owner.get_full_name(), project_name=project.name %} +

Hi {{old_owner_name}},

+

{{ new_owner_name}} has accepted your offer and will become the new project owner of "{{project_name}}".

+ {% endtrans %} + + {% if reason %} + {% trans new_owner_name=new_owner.get_full_name()%}

{{ new_owner_name }} says:

{% endtrans %} +

{{reason}}

+ {% endif %} + + {% trans %} +

From now on, your new status for this project will be "admin".

+ {% endtrans %} + + {% trans signature=sr("signature") %} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/templates/emails/transfer_accept-body-text.jinja b/taiga/projects/templates/emails/transfer_accept-body-text.jinja new file mode 100644 index 000000000..29c9163c0 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_accept-body-text.jinja @@ -0,0 +1,26 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans old_owner_name=old_owner.get_full_name(), new_owner_name=new_owner.get_full_name(), project_name=project.name %} +Hi {{old_owner_name}}, +{{ new_owner_name}} has accepted your offer and will become the new project owner of "{{project_name}}". +{% endtrans %} + +{% if reason %} +{% trans new_owner_name=new_owner.get_full_name()%}{{ new_owner_name }} says:{% endtrans %} +{{reason}} +{% endif %} + +{% trans %} +From now on, your new status for this project will be "admin". +{% endtrans %} + +{% trans signature=sr("signature") %} +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_accept-subject.jinja b/taiga/projects/templates/emails/transfer_accept-subject.jinja new file mode 100644 index 000000000..175161b0c --- /dev/null +++ b/taiga/projects/templates/emails/transfer_accept-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %} +[{{project}}] Project ownership transfer offer accepted! +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_reject-body-html.jinja b/taiga/projects/templates/emails/transfer_reject-body-html.jinja new file mode 100644 index 000000000..f38ee64a9 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_reject-body-html.jinja @@ -0,0 +1,36 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans owner_name=project.owner.get_full_name(), rejecter_name=rejecter.get_full_name(), project_name=project.name %} +

Hi {{ owner_name }},

+

{{ rejecter_name }} has declined your offer and will not become the new project owner of "{{project_name}}".

+ {% endtrans %} + + {% if reason %} + {% trans rejecter_name=rejecter.get_full_name()%} +

{{ rejecter_name }} says:

+ {% endtrans %} +

{{ reason }}

+ {% endif %} + + {% trans %} +

If you want, you can still try to transfer the project ownership to a different person.

+ {% endtrans %} + + + {% trans %}Request transfer to a different person{% endtrans %} + + + {% trans signature=sr("signature") %} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/templates/emails/transfer_reject-body-text.jinja b/taiga/projects/templates/emails/transfer_reject-body-text.jinja new file mode 100644 index 000000000..1e85d8048 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_reject-body-text.jinja @@ -0,0 +1,29 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans owner_name=project.owner.get_full_name(), rejecter_name=rejecter.get_full_name(), project_name=project.name %} +Hi {{owner_name}}, +{{ rejecter_name}} has declined your offer and will not become the new project owner of "{{project_name}}". +{% endtrans %} + +{% if reason %} +{% trans rejecter_name=rejecter.get_full_name()%}{{ rejecter_name }} says:{% endtrans %} +{{ reason }} +{% endif %} + +{% trans %} +If you want, you can still try to transfer the project ownership to a different person. +{% endtrans %} + +{% trans %}Request transfer to a different person:{% endtrans %} +{{ resolve_front_url("project-admin", project.slug) }} + +{% trans signature=sr("signature") %} +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_reject-subject.jinja b/taiga/projects/templates/emails/transfer_reject-subject.jinja new file mode 100644 index 000000000..743fc1665 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_reject-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %} +[{{project}}] Project ownership transfer declined +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_request-body-html.jinja b/taiga/projects/templates/emails/transfer_request-body-html.jinja new file mode 100644 index 000000000..a2baef368 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_request-body-html.jinja @@ -0,0 +1,27 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans owner_name=project.owner.get_full_name(), requester_name=requester.get_full_name(), project_name=project.name %} +

Hi {{owner_name}},

+

{{ requester_name }} has requested to become the project owner of "{{project_name}}".

+ {% endtrans %} + + {% trans %} +

Please, click on "Continue" if you would like to start the project transfer from the administration panel.

+ {% endtrans %} + + {% trans %}Continue{% endtrans %} + + {% trans signature=sr("signature") %} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/templates/emails/transfer_request-body-text.jinja b/taiga/projects/templates/emails/transfer_request-body-text.jinja new file mode 100644 index 000000000..049c1fac8 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_request-body-text.jinja @@ -0,0 +1,23 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans owner_name=project.owner.get_full_name(), requester_name=requester.get_full_name(), project_name=project.name %} +Hi {{owner_name}}, +{{ requester_name }} has requested to become the project owner of "{{project_name}}". +{% endtrans %} + +{% trans %} +Please, go to your project settings if you would like to start the project transfer from the administration panel. +{% endtrans %} + +{{ _("Go to your project settings:") }} {{ resolve_front_url("project-admin", project.slug) }} + +{% trans signature=sr("signature") %} +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_request-subject.jinja b/taiga/projects/templates/emails/transfer_request-subject.jinja new file mode 100644 index 000000000..93b5487c2 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_request-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %} +[{{project}}] Project ownership transfer request +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_start-body-html.jinja b/taiga/projects/templates/emails/transfer_start-body-html.jinja new file mode 100644 index 000000000..25a9d821e --- /dev/null +++ b/taiga/projects/templates/emails/transfer_start-body-html.jinja @@ -0,0 +1,35 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans owner_name=project.owner.get_full_name(), receiver_name=receiver.get_full_name(), project_name=project.name %} +

Hi {{receiver_name}},

+

{{ owner_name }}, the current project owner at "{{project_name}}" would like you to become the new project owner.

+ {% endtrans %} + + {% if reason %} + {% trans owner_name=project.owner.get_full_name() %} +

{{ owner_name }} says:

+ {% endtrans %} + +

{{ reason }}

+ {% endif %} + + {% trans %} +

Please, click on "Continue" to either accept or reject this proposal.

+ {% endtrans %} + + {{ _("Continue") }} + + {% trans signature=sr("signature") %} +

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/projects/templates/emails/transfer_start-body-text.jinja b/taiga/projects/templates/emails/transfer_start-body-text.jinja new file mode 100644 index 000000000..47f49b2e7 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_start-body-text.jinja @@ -0,0 +1,28 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans owner_name=project.owner.get_full_name(), receiver_name=receiver.get_full_name(), project_name=project.name %} +Hi {{receiver_name}}, +{{ owner_name }}, the current project owner at "{{project_name}}" would like you to become the new project owner. +{% endtrans %} + +{% if reason %}{% trans owner_name=project.owner.get_full_name() %}{{ owner_name }} says:{% endtrans %} + +{{ reason }} +{% endif %} + +{% trans %} +Please, go to the following link to either accept or reject this proposal.

+{% endtrans %} + +{{ _("Accept or reject the project ownership transfer:") }} {{ resolve_front_url("project-transfer", project.slug, project.transfer_token) }} + +{% trans signature=sr("signature") %} +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_start-subject.jinja b/taiga/projects/templates/emails/transfer_start-subject.jinja new file mode 100644 index 000000000..0028f972d --- /dev/null +++ b/taiga/projects/templates/emails/transfer_start-subject.jinja @@ -0,0 +1,11 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans project=project.name|safe %} +[{{project}}] Project ownership transfer offer +{% endtrans %} diff --git a/taiga/projects/throttling.py b/taiga/projects/throttling.py new file mode 100644 index 000000000..4655432ef --- /dev/null +++ b/taiga/projects/throttling.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import throttling + + +class MembershipsRateThrottle(throttling.ThrottleByActionMixin, throttling.UserRateThrottle): + scope = "create-memberships" + throttled_actions = ["create", "resend_invitation", "bulk_create"] + + def exceeded_throttling_restriction(self, request, view): + self.created_memberships = 0 + if view.action in ["create", "resend_invitation"]: + self.created_memberships = 1 + elif view.action == "bulk_create": + self.created_memberships = len(request.DATA.get("bulk_memberships", [])) + return len(self.history) + self.created_memberships > self.num_requests + + def throttle_success(self, request, view): + for i in range(self.created_memberships): + self.history.insert(0, self.now) + + self.cache.set(self.key, self.history, self.duration) + return True diff --git a/taiga/projects/translations.py b/taiga/projects/translations.py new file mode 100644 index 000000000..b87b7f392 --- /dev/null +++ b/taiga/projects/translations.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# NOTE: This file is useful to translate default projects templates. Remember update +# when taiga/projects/fixtures/initial_project_templates.json change. + +from django.utils.translation import gettext as _ + + +########################## +## Default template information +########################## + +# Translators: Name of scrum project template. +_("Scrum") +# Translators: Description of scrum project template. +_("The agile product backlog in Scrum is a prioritized features list, containing short descriptions of all functionality desired in the product. When applying Scrum, it's not necessary to start a project with a lengthy, upfront effort to document all requirements. The Scrum product backlog is then allowed to grow and change as more is learned about the product and its customers") + +# Translators: Name of kanban project template. +_("Kanban") +# Translators: Description of kanban project template. +_("Kanban is a method for managing knowledge work with an emphasis on just-in-time delivery while not overloading the team members. In this approach, the process, from definition of a task to its delivery to the customer, is displayed for participants to see and team members pull work from a queue.") + + +########################## +## US Points +########################## + +# Translators: User story point value (value = undefined) +_("?") +# Translators: User story point value (value = 0) +_("0") +# Translators: User story point value (value = 0.5) +_("1/2") +# Translators: User story point value (value = 1) +_("1") +# Translators: User story point value (value = 2) +_("2") +# Translators: User story point value (value = 3) +_("3") +# Translators: User story point value (value = 5) +_("5") +# Translators: User story point value (value = 8) +_("8") +# Translators: User story point value (value = 10) +_("10") +# Translators: User story point value (value = 13) +_("13") +# Translators: User story point value (value = 20) +_("20") +# Translators: User story point value (value = 40) +_("40") + + +########################## +## US Statuses +########################## + +# Translators: User story status +_("New") + +# Translators: User story status +_("Ready") + +# Translators: User story status +_("In progress") + +# Translators: User story status +_("Ready for test") + +# Translators: User story status +_("Done") + +# Translators: User story status +_("Archived") + + +########################## +## Task Statuses +########################## + +# Translators: Task status +_("New") +# Translators: Task status +_("In progress") +# Translators: Task status +_("Ready for test") +# Translators: Task status +_("Closed") +# Translators: Task status +_("Needs Info") + + +########################## +## Issue Statuses +########################## + +# Translators: Issue status +_("New") +# Translators: Issue status +_("In progress") +# Translators: Issue status +_("Ready for test") +# Translators: Issue status +_("Closed") +# Translators: Issue status +_("Needs Info") +# Translators: Issue status +_("Postponed") +# Translators: Issue status +_("Rejected") + + +########################## +## Issue Statuses +########################## + +# Translators: Issue type +_("Bug") +# Translators: Issue type +_("Question") +# Translators: Issue type +_("Enhancement") + + +########################## +## Priorities +########################## + +# Translators: Issue priority +_("Low") +# Translators: Issue priority +_("Normal") +# Translators: Issue priority +_("High") + + +########################## +## Severities +########################## +# Translators: Issue severity +_("Wishlist") +# Translators: Issue severity +_("Minor") +# Translators: Issue severity +_("Normal") +# Translators: Issue severity +_("Important") +# Translators: Issue severity +_("Critical") + + +########################## +## Roles +########################## +# Translators: User role +_("UX") +# Translators: User role +_("Design") +# Translators: User role +_("Front") +# Translators: User role +_("Back") +# Translators: User role +_("Product Owner") +# Translators: User role +_("Stakeholder") diff --git a/taiga/projects/userstories/__init__.py b/taiga/projects/userstories/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/userstories/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/userstories/admin.py b/taiga/projects/userstories/admin.py new file mode 100644 index 000000000..1e8dba385 --- /dev/null +++ b/taiga/projects/userstories/admin.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin + +from taiga.projects.attachments.admin import AttachmentInline +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + +from . import models + + +class RolePointsInline(admin.TabularInline): + model = models.RolePoints + sortable_field_name = 'role' + readonly_fields = ["user_story", "role", "points"] + can_delete = False + extra = 0 + max_num = 0 + + +class RolePointsAdmin(admin.ModelAdmin): + list_display = ["user_story", "role", "points"] + list_display_links = list_display + readonly_fields = ["user_story", "role", "points"] + + +class UserStoryAdmin(admin.ModelAdmin): + list_display = ["project", "milestone", "ref", "subject",] + list_display_links = ["ref", "subject",] + inlines = [RolePointsInline, WatchedInline, VoteInline] + raw_id_fields = ["project"] + search_fields = ["subject", "description", "id", "ref"] + + def get_object(self, *args, **kwargs): + self.obj = super().get_object(*args, **kwargs) + return self.obj + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if (db_field.name in ["status", "milestone", "generated_from_issue", + "generated_from_task"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter( + project=self.obj.project) + elif (db_field.name in ["owner", "assigned_to"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.obj.project) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + if (db_field.name in ["watchers"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related.parent_model.objects.filter( + memberships__project=self.obj.project) + elif (db_field.name in ["assigned_users"] + and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.obj.project) + return super().formfield_for_manytomany(db_field, request, **kwargs) + + +admin.site.register(models.UserStory, UserStoryAdmin) +admin.site.register(models.RolePoints, RolePointsAdmin) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py new file mode 100644 index 000000000..7ebb40212 --- /dev/null +++ b/taiga/projects/userstories/api.py @@ -0,0 +1,518 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.db import transaction +from django.db.models import Max + +from django.utils.translation import gettext as _ +from django.http import HttpResponse + +from taiga.base import filters as base_filters +from taiga.base import exceptions as exc +from taiga.base import response +from taiga.base import status +from taiga.base.decorators import list_route +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelListViewSet +from taiga.base.api.utils import get_object_or_error +from taiga.base.utils import json +from taiga.base.utils.db import get_object_or_none + +from taiga.projects.history.mixins import HistoryResourceMixin +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, Swimlane +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 +from taiga.projects.tagging.api import TaggedResourceMixin +from taiga.projects.votes.mixins.viewsets import VotedResourceMixin +from taiga.projects.votes.mixins.viewsets import VotersViewSetMixin +from taiga.projects.userstories.utils import attach_extra_info + +from . import filters +from . import models +from . import permissions +from . import serializers +from . import services +from . import validators + + +class UserStoryViewSet(AssignedUsersSignalMixin, OCCResourceMixin, + VotedResourceMixin, HistoryResourceMixin, + WatchedResourceMixin, ByRefMixin, TaggedResourceMixin, + BlockedByProjectMixin, ModelCrudViewSet): + validator_class = validators.UserStoryValidator + queryset = models.UserStory.objects.all() + permission_classes = (permissions.UserStoryPermission,) + filter_backends = (base_filters.CanViewUsFilterBackend, + filters.DashboardFilter, + filters.EpicFilter, + filters.SwimlanesFilter, + filters.UserStoryStatusesFilter, + filters.UserStoriesRoleFilter, + filters.AssignedUsersFilter, + base_filters.OwnersFilter, + base_filters.AssignedToFilter, + base_filters.TagsFilter, + base_filters.WatchersFilter, + base_filters.QFilter, + base_filters.CreatedDateFilter, + base_filters.ModifiedDateFilter, + base_filters.FinishDateFilter, + base_filters.MilestoneEstimatedStartFilter, + base_filters.MilestoneEstimatedFinishFilter, + base_filters.OrderByFilterMixin) + filter_fields = ["project", + "project__slug", + "milestone", + "milestone__isnull", + "is_closed", + "status__is_archived", + "status__is_closed"] + order_by_fields = ["backlog_order", + "sprint_order", + "kanban_order", + "epic_order", + "project", + "milestone", + "status", + "created_date", + "modified_date", + "assigned_to", + "subject", + "total_voters"] + + def get_serializer_class(self, *args, **kwargs): + if self.action in ["retrieve", "by_ref"]: + return serializers.UserStoryNeighborsSerializer + + if self.action == "list": + if self.request.QUERY_PARAMS.get('only_ref', False): + return serializers.UserStoryOnlyRefSerializer + elif self.request.QUERY_PARAMS.get('dashboard', False): + return serializers.UserStoryLightSerializer + return serializers.UserStoryListSerializer + + return serializers.UserStorySerializer + + def get_queryset(self): + qs = super().get_queryset() + + if self.action == "list" and self.request.QUERY_PARAMS.get('only_ref', False): + return qs + + qs = qs.select_related("project", + "status", + "assigned_to") + + if self.action == "list" and self.request.QUERY_PARAMS.get('dashboard', False): + return qs + + qs = qs.select_related("milestone", + "owner", + "generated_from_issue", + "generated_from_task") + + qs = qs.prefetch_related("assigned_users") + include_attachments = "include_attachments" in self.request.QUERY_PARAMS + include_tasks = "include_tasks" in self.request.QUERY_PARAMS + + epic_id = self.request.QUERY_PARAMS.get("epic", None) + # We can be filtering by more than one epic so epic_id can consist + # of different ids separated by comma. In that situation we will use + # only the first + if epic_id is not None: + epic_id = epic_id.split(",")[0] + + qs = attach_extra_info(qs, user=self.request.user, + include_attachments=include_attachments, + include_tasks=include_tasks, + epic_id=epic_id) + return qs + + # Updating some attributes of the userstory can affect the ordering in the backlog, kanban or taskboard + # These three methods generate a key for the user story and can be used to be compared before and after + # saving. If there is any difference it means an extra ordering update must be done + def _backlog_order_key(self, obj): + return f"{obj.project_id}-{obj.backlog_order}" + + def _kanban_order_key(self, obj): + return f"{obj.project_id}-{obj.swimlane_id}-{obj.status_id}-{obj.kanban_order}" + + def _sprint_order_key(self, obj): + return f"{obj.project_id}-{obj.milestone_id}-{obj.sprint_order}" + + def _add_taiga_info_headers(self): + try: + project_id = int(self.request.QUERY_PARAMS.get("project", None)) + except TypeError: + project_id = None + + milestone = self.request.QUERY_PARAMS.get("milestone", "").lower() + + if project_id and milestone == "null": + # Add this header only to draw the backlog (milestone=null) + total_backlog_userstories = self.queryset.filter(project_id=project_id, milestone__isnull=True).count() + self.headers["Taiga-Info-Backlog-Total-Userstories"] = total_backlog_userstories + + if project_id: + # Add this header to show if there are user stories not assigned to any swimlane. + # Useful to show _Unclassified user stories_ option in swimlane selector at create/edit forms. + without_swimlane = self.queryset.filter(project_id=project_id, swimlane__isnull=True).exists() + self.headers["Taiga-Info-Userstories-Without-Swimlane"] = json.dumps(without_swimlane) + + def list(self, request, *args, **kwargs): + res = super().list(request, *args, **kwargs) + self._add_taiga_info_headers() + return res + + def pre_validate(self): + # ## start-hack-reorder ## + # Usefull to check if order fields should be updated. + self._old_backlog_order_key = self._backlog_order_key(self.object) + self._old_sprint_order_key = self._sprint_order_key(self.object) + self._old_kanban_order_key = self._kanban_order_key(self.object) + # ## end-hack-reorder ## + + def pre_conditions_on_save(self, obj): + super().pre_conditions_on_save(obj) + + if obj.milestone_id and obj.milestone.project != obj.project: + raise exc.PermissionDenied(_("You don't have permissions to set this sprint " + "to this user story.")) + + if obj.status_id and obj.status.project != obj.project: + raise exc.PermissionDenied(_("You don't have permissions to set this status " + "to this user story.")) + + if obj.swimlane_id and obj.swimlane.project != obj.project: + raise exc.PermissionDenied(_("You don't have permissions to set this swimlane " + "to this user story.")) + + def pre_save(self, obj): + # ## start-hack-reorder ## + if obj.id: + if self._old_kanban_order_key != self._kanban_order_key(self.object): + # The user story is moved to other status, swimlane or project. + # It should be at the end of the cell (swimlanes / status). + obj.kanban_order = models.UserStory.NEW_KANBAN_ORDER() + # ## end-hack-reorder ## + + # ## start-hack-rolepoints ## + # This is very ugly hack, but having + # restframework is the only way to do it. + # + # NOTE: code moved as is from serializer + # to api because is not serializer logic. + related_data = getattr(obj, "_related_data", {}) + self._role_points = related_data.pop("role_points", None) + # ## end-hack-rolepoints ## + + if not obj.id: + obj.owner = self.request.user + + super().pre_save(obj) + + def _reorder_if_needed(self, obj, old_order_key, order_key, order_attr, + project, status=None, milestone=None): + # TODO: The goal should be erase this function. + # + # Executes the extra ordering if there is a difference in the ordering keys + if old_order_key != order_key: + data = [{"us_id": obj.id, "order": getattr(obj, order_attr)}] + return services.update_userstories_order_in_bulk(data, + order_attr, + project, + status=status, + milestone=milestone) + return {} + + def post_save(self, obj, created=False): + # ## start-hack-reorder ## + # TODO: The goal should be erase this hack. + if not created: + # Let's reorder the related stuff after edit the element + orders_updated = {} + updated = self._reorder_if_needed(obj, + self._old_backlog_order_key, + self._backlog_order_key(obj), + "backlog_order", + obj.project) + orders_updated.update(updated) + updated = self._reorder_if_needed(obj, + self._old_sprint_order_key, + self._sprint_order_key(obj), + "sprint_order", + obj.project, + milestone=obj.milestone) + orders_updated.update(updated) + self.headers["Taiga-Info-Order-Updated"] = json.dumps(orders_updated) + # ## end-hack-reorder ## + + # ## starts-hack-rolepoints ## + # Code related to the hack of pre_save method. + # Rather, this is the continuation of it. + if self._role_points: + Points = apps.get_model("projects", "Points") + RolePoints = apps.get_model("userstories", "RolePoints") + + for role_id, points_id in self._role_points.items(): + try: + role_points = RolePoints.objects.get(role__id=role_id, user_story_id=obj.pk, + role__computable=True) + except (ValueError, RolePoints.DoesNotExist): + raise exc.BadRequest({ + "points": _("Invalid role id '{role_id}'").format(role_id=role_id) + }) + + try: + role_points.points = Points.objects.get(id=points_id, project_id=obj.project_id) + except (ValueError, Points.DoesNotExist): + raise exc.BadRequest({ + "points": _("Invalid points id '{points_id}'").format(points_id=points_id) + }) + + role_points.save() + # ## end-hack-rolepoints ## + + super().post_save(obj, created) + + @transaction.atomic + def create(self, *args, **kwargs): + response = super().create(*args, **kwargs) + + # if US has been promoted from issue, add a comment to the origin (issue) + if response.status_code == status.HTTP_201_CREATED: + for generated_from in ['generated_from_issue', 'generated_from_task']: + generator = getattr(self.object, generated_from) + if generator: + generator.save() + + comment = _("Generating the user story #{ref} - {subject}") + comment = comment.format(ref=self.object.ref, subject=self.object.subject) + history = take_snapshot(generator, + comment=comment, + user=self.request.user) + + self.send_notifications(generator, history) + + return response + + def update(self, request, *args, **kwargs): + self.object = self.get_object_or_none() + + # If you move the US to another project... + project_id = request.DATA.get('project', None) + if project_id and self.object and self.object.project.id != project_id: + try: + new_project = Project.objects.get(pk=project_id) + self.check_permissions(request, "destroy", self.object) + self.check_permissions(request, "create", new_project) + + sprint_id = request.DATA.get('milestone', None) + if sprint_id is not None and new_project.milestones.filter(pk=sprint_id).count() == 0: + request.DATA['milestone'] = None + + swimlane_id = request.DATA.get('swimlane', None) + if swimlane_id is not None and new_project.swimlanes.filter(pk=swimlane_id).count() == 0: + request.DATA['swimlane'] = None + + status_id = request.DATA.get('status', None) + if status_id is not None: + try: + old_status = self.object.project.us_statuses.get(pk=status_id) + new_status = new_project.us_statuses.get(slug=old_status.slug) + request.DATA['status'] = new_status.id + except UserStoryStatus.DoesNotExist: + request.DATA['status'] = new_project.default_us_status_id + except Project.DoesNotExist: + return response.BadRequest(_("The project doesn't exist")) + + return super().update(request, *args, **kwargs) + + @list_route(methods=["GET"]) + def filters_data(self, request, *args, **kwargs): + project_id = request.QUERY_PARAMS.get("project", None) + project = get_object_or_error(Project, request.user, id=project_id) + + filter_backends = self.get_filter_backends() + statuses_filter_backends = (f for f in filter_backends if f != filters.UserStoryStatusesFilter) + assigned_to_filter_backends = (f for f in filter_backends if f != base_filters.AssignedToFilter) + assigned_users_filter_backends = (f for f in filter_backends if f != filters.AssignedUsersFilter) + owners_filter_backends = (f for f in filter_backends if f != base_filters.OwnersFilter) + epics_filter_backends = (f for f in filter_backends if f != filters.EpicFilter) + roles_filter_backends = (f for f in filter_backends if f != base_filters.RoleFilter) + tags_filter_backends = (f for f in filter_backends if f != base_filters.TagsFilter) + + queryset = self.get_queryset() + # assigned_to is kept for retro-compatibility reasons; but currently filters + # are using assigned_users + querysets = { + "statuses": self.filter_queryset(queryset, filter_backends=statuses_filter_backends), + "assigned_to": self.filter_queryset(queryset, filter_backends=assigned_to_filter_backends), + "assigned_users": self.filter_queryset(queryset, filter_backends=assigned_users_filter_backends), + "owners": self.filter_queryset(queryset, filter_backends=owners_filter_backends), + "tags": self.filter_queryset(queryset, filter_backends=tags_filter_backends), + "epics": self.filter_queryset(queryset, filter_backends=epics_filter_backends), + "roles": self.filter_queryset(queryset, filter_backends=roles_filter_backends) + } + + return response.Ok(services.get_userstories_filters_data(project, querysets)) + + @list_route(methods=["GET"]) + def csv(self, request): + uuid = request.QUERY_PARAMS.get("uuid", None) + if uuid is None: + return response.NotFound() + + project = get_object_or_error(Project, request.user, userstories_csv_uuid=uuid) + queryset = project.user_stories.all().order_by('ref') + data = services.userstories_to_csv(project, queryset) + csv_response = HttpResponse(data.getvalue(), content_type='application/csv; charset=utf-8') + csv_response['Content-Disposition'] = 'attachment; filename="userstories.csv"' + return csv_response + + @list_route(methods=["POST"]) + def bulk_create(self, request, **kwargs): + validator = validators.UserStoriesBulkValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data + project = Project.objects.get(id=data["project_id"]) + self.check_permissions(request, 'bulk_create', project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + user_stories = services.create_userstories_in_bulk( + data["bulk_stories"], project=project, owner=request.user, + status_id=data.get("status_id") or project.default_us_status_id, + swimlane_id=data.get("swimlane_id", None), + callback=self.post_save, precall=self.pre_save) + + user_stories = self.get_queryset().filter(id__in=[i.id for i in user_stories]) + for user_story in user_stories: + self.persist_history_snapshot(obj=user_story) + + user_stories_serialized = self.get_serializer_class()(user_stories, many=True) + + return response.Ok(user_stories_serialized.data) + 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_error(Project, request.user, pk=data["project_id"]) + milestone = get_object_or_error(Milestone, request.user, pk=data["milestone_id"]) + + self.check_permissions(request, "bulk_update_milestone", project) + + services.update_userstories_milestone_in_bulk(data["bulk_stories"], milestone) + services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) + + return response.NoContent() + + @list_route(methods=["POST"]) + def bulk_update_backlog_order(self, request, **kwargs): + # Validate data + validator = validators.UpdateUserStoriesBacklogOrderBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + data = validator.data + + # Get and validate project permissions + project = get_object_or_error(Project, request.user, pk=data["project_id"]) + self.check_permissions(request, "bulk_update_order", project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + # Get milestone + milestone = None + milestone_id = data.get("milestone_id", None) + if milestone_id is not None: + milestone = get_object_or_error(Milestone, request.user, pk=milestone_id, project=project) + + # Get after_userstory + after_userstory = None + after_userstory_id = data.get("after_userstory_id", None) + if after_userstory_id is not None: + after_userstory = get_object_or_error(models.UserStory, request.user, pk=after_userstory_id, project=project) + + # Get before_userstory + before_userstory = None + before_userstory_id = data.get("before_userstory_id", None) + if before_userstory_id is not None: + before_userstory = get_object_or_error(models.UserStory, request.user, pk=before_userstory_id, project=project) + + ret = services.update_userstories_backlog_or_sprint_order_in_bulk(user=request.user, + project=project, + milestone=milestone, + after_userstory=after_userstory, + before_userstory=before_userstory, + bulk_userstories=data["bulk_userstories"]) + return response.Ok(ret) + + @list_route(methods=["POST"]) + def bulk_update_kanban_order(self, request, **kwargs): + # Validate data + validator = validators.UpdateUserStoriesKanbanOrderBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + data = validator.data + + # Get and validate project permissions + project = get_object_or_error(Project, request.user, pk=data["project_id"]) + self.check_permissions(request, "bulk_update_order", project) + if project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + # Get status + status = get_object_or_error(UserStoryStatus, request.user, pk=data["status_id"], project=project) + + # Get swimlane + swimlane = None + swimlane_id = data.get("swimlane_id", None) + if swimlane_id is not None: + swimlane = get_object_or_error(Swimlane, request.user, pk=swimlane_id, project=project) + + # Get after_userstory + after_userstory = None + after_userstory_id = data.get("after_userstory_id", None) + if after_userstory_id is not None: + after_userstory = get_object_or_error(models.UserStory, request.user, pk=after_userstory_id, project=project) + + # Get before_userstory + before_userstory = None + before_userstory_id = data.get("before_userstory_id", None) + if before_userstory_id is not None: + before_userstory = get_object_or_error(models.UserStory, request.user, pk=before_userstory_id, project=project) + + ret = services.update_userstories_kanban_order_in_bulk(user=request.user, + project=project, + status=status, + swimlane=swimlane, + after_userstory=after_userstory, + before_userstory=before_userstory, + bulk_userstories=data["bulk_userstories"]) + return response.Ok(ret) + + +class UserStoryVotersViewSet(VotersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.UserStoryVotersPermission,) + resource_model = models.UserStory + + +class UserStoryWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.UserStoryWatchersPermission,) + resource_model = models.UserStory diff --git a/taiga/projects/userstories/apps.py b/taiga/projects/userstories/apps.py new file mode 100644 index 000000000..a2c30994e --- /dev/null +++ b/taiga/projects/userstories/apps.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.apps import AppConfig +from django.apps import apps +from django.db.models import signals + + +def connect_userstories_signals(): + from taiga.projects.tagging import signals as tagging_handlers + from . import signals as handlers + + # When deleting user stories we must disable task signals while deleting and + # enabling them in the end + signals.pre_delete.connect(handlers.disable_task_signals, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid='disable_task_signals') + + signals.post_delete.connect(handlers.enable_tasks_signals, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid='enable_tasks_signals') + + # Cached prev object version + signals.pre_save.connect(handlers.cached_prev_us, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="cached_prev_us") + + # Tasks + signals.post_save.connect(handlers.update_milestone_of_tasks_when_edit_us, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="update_milestone_of_tasks_when_edit_us") + + # Open/Close US and Milestone + signals.post_save.connect(handlers.try_to_close_or_open_us_and_milestone_when_create_or_edit_us, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us") + signals.post_delete.connect(handlers.try_to_close_milestone_when_delete_us, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="try_to_close_milestone_when_delete_us") + + # Tags + signals.pre_save.connect(tagging_handlers.tags_normalization, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="tags_normalization_user_story") + + +def connect_userstories_custom_attributes_signals(): + from taiga.projects.custom_attributes import signals as custom_attributes_handlers + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_user_story, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="create_custom_attribute_value_when_create_user_story") + + +def connect_all_userstories_signals(): + connect_userstories_signals() + connect_userstories_custom_attributes_signals() + + +def disconnect_userstories_signals(): + signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="cached_prev_us") + + signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="update_role_points_when_create_or_edit_us") + + signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="update_milestone_of_tasks_when_edit_us") + + signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us") + signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="try_to_close_milestone_when_delete_us") + + signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="tags_normalization_user_story") + + +def disconnect_userstories_custom_attributes_signals(): + signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="create_custom_attribute_value_when_create_user_story") + + +def disconnect_all_userstories_signals(): + disconnect_userstories_signals() + disconnect_userstories_custom_attributes_signals() + + +class UserStoriesAppConfig(AppConfig): + name = "taiga.projects.userstories" + verbose_name = "User Stories" + watched_types = ["userstories.userstory", ] + + def ready(self): + connect_all_userstories_signals() diff --git a/taiga/projects/userstories/filters.py b/taiga/projects/userstories/filters.py new file mode 100644 index 000000000..97ddb9d3b --- /dev/null +++ b/taiga/projects/userstories/filters.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.contrib.auth.models import AnonymousUser +from django.db.models import Q, OuterRef, Subquery +from django.utils.translation import gettext as _ + +from taiga.base import filters + + +def get_assigned_users_filter(model, value): + assigned_users_ids = model.objects.order_by().filter( + assigned_users__in=value, id=OuterRef('pk')).values('pk') + + assigned_user_filter = Q(pk__in=Subquery(assigned_users_ids)) + assigned_to_filter = Q(assigned_to__in=value) + + return Q(assigned_user_filter | assigned_to_filter) + + +class EpicFilter(filters.BaseRelatedFieldsFilter): + filter_name = "epics" + param_name = "epic" + exclude_param_name = 'exclude_epic' + + +class SwimlanesFilter(filters.BaseRelatedFieldsFilter): + filter_name = 'swimlane' + param_name = "swimnlane" + exclude_param_name = 'exclude_swimlane' + + +class UserStoryStatusesFilter(filters.StatusesFilter): + def filter_queryset(self, request, queryset, view): + project_id = None + if "project" in request.QUERY_PARAMS: + try: + project_id = int(request.QUERY_PARAMS["project"]) + except ValueError: + logger.error("Filtering user stories by status. Project value should be an integer: {}".format( + request.QUERY_PARAMS["project"])) + raise exc.BadRequest(_("'project' must be an integer value.")) + + if project_id: + queryset = queryset.filter(status__project_id=project_id) + + return super().filter_queryset(request, queryset, view) + + +class AssignedUsersFilter(filters.BaseRelatedFieldsFilter): + filter_name = 'assigned_users' + exclude_param_name = 'exclude_assigned_users' + + 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") + + if None in value: + value.remove(None) + assigned_users_ids = UserStoryModel.objects.order_by().filter( + assigned_users__isnull=True, + id=OuterRef('pk')).values('pk') + + assigned_user_filter_none = Q(pk__in=Subquery(assigned_users_ids)) + assigned_to_filter_none = Q(assigned_to__isnull=True) + + return (get_assigned_users_filter(UserStoryModel, value) + | Q(assigned_user_filter_none, assigned_to_filter_none)) + else: + return get_assigned_users_filter(UserStoryModel, value) + + return None + + +class UserStoriesRoleFilter(filters.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') + + 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(get_assigned_users_filter(user_story_model, memberships))) + ) + + return filters.FilterBackend.filter_queryset(self, request, queryset, view) + + +class DashboardFilter(filters.FilterBackend): + """ + This filter improves performance for dashboard queries. + Only search in the user projects + """ + filter_name = 'dashboard' + param_name = "dashboard" + + def _filter_user_projects(self, request): + membership_model = apps.get_model('projects', 'Membership') + if isinstance(request.user, AnonymousUser): + return None + else: + memberships_project_ids = membership_model.objects.filter(user=request.user).values( + 'project_id') + + return Subquery(memberships_project_ids) + + def filter_queryset(self, request, queryset, view): + if request.QUERY_PARAMS.get(self.param_name, False): + user_projects_ids_subquery = self._filter_user_projects(request) + + if user_projects_ids_subquery: + queryset = queryset.filter(project_id__in=user_projects_ids_subquery) + + return super().filter_queryset(request, queryset, view) diff --git a/taiga/projects/userstories/migrations/0001_initial.py b/taiga/projects/userstories/migrations/0001_initial.py new file mode 100644 index 000000000..6c6a0e108 --- /dev/null +++ b/taiga/projects/userstories/migrations/0001_initial.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import django.utils.timezone +import django.contrib.postgres.fields +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '__first__'), + ('milestones', '__first__'), + ('projects', '0002_auto_20140903_0920'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0003_auto_20140903_0925'), + ] + + operations = [ + migrations.CreateModel( + name='RolePoints', + fields=[ + ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), + ('points', models.ForeignKey(verbose_name='points', to='projects.Points', related_name='role_points', on_delete=models.CASCADE)), + ('role', models.ForeignKey(verbose_name='role', to='users.Role', related_name='role_points', on_delete=models.CASCADE)), + ], + options={ + 'ordering': ['user_story', 'role'], + 'verbose_name': 'role points', + 'verbose_name_plural': 'role points', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserStory', + fields=[ + ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=list, null=True, size=None, verbose_name='tags')), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('is_blocked', models.BooleanField(default=False, verbose_name='is blocked')), + ('blocked_note', models.TextField(default='', blank=True, verbose_name='blocked note')), + ('ref', models.BigIntegerField(default=None, db_index=True, blank=True, null=True, verbose_name='ref')), + ('is_closed', models.BooleanField(default=False)), + ('is_archived', models.BooleanField(default=False, verbose_name='archived')), + ('order', models.PositiveSmallIntegerField(default=100, verbose_name='order')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('finish_date', models.DateTimeField(blank=True, null=True, verbose_name='finish date')), + ('subject', models.TextField(verbose_name='subject')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('client_requirement', models.BooleanField(default=False, verbose_name='is client requirement')), + ('team_requirement', models.BooleanField(default=False, verbose_name='is team requirement')), + ('assigned_to', models.ForeignKey(null=True, verbose_name='assigned to', to=settings.AUTH_USER_MODEL, related_name='userstories_assigned_to_me', blank=True, default=None, on_delete=models.SET_NULL)), + ('generated_from_issue', models.ForeignKey(blank=True, null=True, verbose_name='generated from issue', to='issues.Issue', related_name='generated_user_stories', on_delete=models.SET_NULL)), + ('milestone', models.ForeignKey(null=True, verbose_name='milestone', to='milestones.Milestone', related_name='user_stories', blank=True, default=None, on_delete=models.SET_NULL)), + ('owner', models.ForeignKey(blank=True, null=True, verbose_name='owner', to=settings.AUTH_USER_MODEL, related_name='owned_user_stories', on_delete=models.CASCADE)), + ('points', models.ManyToManyField(through='userstories.RolePoints', related_name='userstories', to='projects.Points', verbose_name='points')), + ('project', models.ForeignKey(verbose_name='project', to='projects.Project', related_name='user_stories', on_delete=models.CASCADE)), + ('status', models.ForeignKey(blank=True, null=True, verbose_name='status', to='projects.UserStoryStatus', related_name='user_stories', on_delete=models.SET_NULL)), + ('watchers', models.ManyToManyField(to=settings.AUTH_USER_MODEL, related_name='userstories_userstory+', blank=True, null=True, verbose_name='watchers')), + ], + options={ + 'ordering': ['project', 'order', 'ref'], + 'verbose_name': 'user story', + 'verbose_name_plural': 'user stories', + }, + bases=(models.Model,), + ), + migrations.AddField( + model_name='rolepoints', + name='user_story', + field=models.ForeignKey(verbose_name='user story', to='userstories.UserStory', related_name='role_points', on_delete=models.CASCADE), + preserve_default=True, + ), + migrations.AlterUniqueTogether( + name='rolepoints', + unique_together=set([('user_story', 'role')]), + ), + ] diff --git a/taiga/projects/userstories/migrations/0002_auto_20140903_1301.py b/taiga/projects/userstories/migrations/0002_auto_20140903_1301.py new file mode 100644 index 000000000..5ed3a58d6 --- /dev/null +++ b/taiga/projects/userstories/migrations/0002_auto_20140903_1301.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='rolepoints', + name='points', + field=models.ForeignKey(related_name='role_points', to='projects.Points', null=True, verbose_name='points', on_delete=models.CASCADE), + ), + ] diff --git a/taiga/projects/userstories/migrations/0003_userstory_order_fields.py b/taiga/projects/userstories/migrations/0003_userstory_order_fields.py new file mode 100644 index 000000000..af37a853c --- /dev/null +++ b/taiga/projects/userstories/migrations/0003_userstory_order_fields.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db.models import F + +def copy_backlog_order_to_kanban_order(apps, schema_editor): + UserStory = apps.get_model("userstories", "UserStory") + UserStory.objects.all().update(kanban_order=F("backlog_order")) + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0002_auto_20140903_1301'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userstory', + options={'verbose_name_plural': 'user stories', 'ordering': ['project', 'backlog_order', 'ref'], 'verbose_name': 'user story'}, + ), + migrations.RenameField( + model_name='userstory', + old_name='order', + new_name='backlog_order', + ), + + migrations.AlterField( + model_name='userstory', + name='backlog_order', + field=models.IntegerField(default=1, verbose_name='backlog order'), + ), + + migrations.AddField( + model_name='userstory', + name='sprint_order', + field=models.IntegerField(default=1, verbose_name='sprint order'), + preserve_default=True, + ), + + migrations.AddField( + model_name='userstory', + name='kanban_order', + field=models.IntegerField(default=1, verbose_name='sprint order'), + preserve_default=True, + ), + + migrations.RunPython(copy_backlog_order_to_kanban_order), + ] diff --git a/taiga/projects/userstories/migrations/0004_auto_20141001_1817.py b/taiga/projects/userstories/migrations/0004_auto_20141001_1817.py new file mode 100644 index 000000000..5ba13fedd --- /dev/null +++ b/taiga/projects/userstories/migrations/0004_auto_20141001_1817.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0003_userstory_order_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='rolepoints', + options={'verbose_name': 'role points', 'verbose_name_plural': 'role points', 'ordering': ['user_story', 'role']}, + ), + migrations.AlterModelOptions( + name='userstory', + options={'verbose_name': 'user story', 'verbose_name_plural': 'user stories', 'ordering': ['project', 'backlog_order', 'ref']}, + ), + ] diff --git a/taiga/projects/userstories/migrations/0005_auto_20141009_1656.py b/taiga/projects/userstories/migrations/0005_auto_20141009_1656.py new file mode 100644 index 000000000..ebd4ced0c --- /dev/null +++ b/taiga/projects/userstories/migrations/0005_auto_20141009_1656.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0004_auto_20141001_1817'), + ] + + operations = [ + migrations.AlterField( + model_name='userstory', + name='generated_from_issue', + field=models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to='issues.Issue', verbose_name='generated from issue', related_name='generated_user_stories', null=True), + ), + ] diff --git a/taiga/projects/userstories/migrations/0006_auto_20141014_1524.py b/taiga/projects/userstories/migrations/0006_auto_20141014_1524.py new file mode 100644 index 000000000..6d53893c3 --- /dev/null +++ b/taiga/projects/userstories/migrations/0006_auto_20141014_1524.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0005_auto_20141009_1656'), + ] + + operations = [ + migrations.AlterField( + model_name='userstory', + name='backlog_order', + field=models.IntegerField(default=10000, verbose_name='backlog order'), + ), + migrations.AlterField( + model_name='userstory', + name='kanban_order', + field=models.IntegerField(default=10000, verbose_name='sprint order'), + ), + migrations.AlterField( + model_name='userstory', + name='sprint_order', + field=models.IntegerField(default=10000, verbose_name='sprint order'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0007_userstory_external_reference.py b/taiga/projects/userstories/migrations/0007_userstory_external_reference.py new file mode 100644 index 000000000..da36e9616 --- /dev/null +++ b/taiga/projects/userstories/migrations/0007_userstory_external_reference.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0006_auto_20141014_1524'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=False, null=False), blank=True, default=None, null=True, size=None, verbose_name='external reference'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/userstories/migrations/0008_auto_20141210_1107.py b/taiga/projects/userstories/migrations/0008_auto_20141210_1107.py new file mode 100644 index 000000000..8b1b35232 --- /dev/null +++ b/taiga/projects/userstories/migrations/0008_auto_20141210_1107.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db import connection +from taiga.projects.userstories.models import * +from taiga.projects.tasks.models import * +from taiga.projects.issues.models import * +from taiga.projects.models import * + +def _fix_tags_model(tags_model): + table_name = tags_model._meta.db_table + query = "select id from (select id, unnest(tags) tag from %s) x where tag LIKE '%%,%%'"%(table_name) + cursor = connection.cursor() + cursor.execute(query) + for row in cursor.fetchall(): + id = row[0] + instance = tags_model.objects.get(id=id) + instance.tags = [tag.replace(",", "") for tag in instance.tags] + instance.save() + + +def fix_tags(apps, schema_editor): + _fix_tags_model(UserStory) + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0007_userstory_external_reference'), + ] + + operations = [ + migrations.RunPython(fix_tags), + ] diff --git a/taiga/projects/userstories/migrations/0009_remove_userstory_is_archived.py b/taiga/projects/userstories/migrations/0009_remove_userstory_is_archived.py new file mode 100644 index 000000000..7d508ce3f --- /dev/null +++ b/taiga/projects/userstories/migrations/0009_remove_userstory_is_archived.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0008_auto_20141210_1107'), + ] + + operations = [ + migrations.RemoveField( + model_name='userstory', + name='is_archived', + ), + ] diff --git a/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py b/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py new file mode 100644 index 000000000..1e179c5ab --- /dev/null +++ b/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import connection +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from taiga.base.utils.contenttypes import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes(verbosity=0) + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT userstory_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM userstories_userstory_watchers INNER JOIN userstories_userstory ON userstories_userstory_watchers.userstory_id = userstories_userstory.id""".format(content_type_id=ContentType.objects.get(model='userstory').id) + cursor = connection.cursor() + cursor.execute(sql) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('userstories', '0009_remove_userstory_is_archived'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='userstory', + name='watchers', + ), + ] diff --git a/taiga/projects/userstories/migrations/0011_userstory_tribe_gig.py b/taiga/projects/userstories/migrations/0011_userstory_tribe_gig.py new file mode 100644 index 000000000..00a754e01 --- /dev/null +++ b/taiga/projects/userstories/migrations/0011_userstory_tribe_gig.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models +import picklefield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0010_remove_userstory_watchers'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='tribe_gig', + field=picklefield.fields.PickledObjectField(editable=False, null=True, default=None, verbose_name='taiga tribe gig', blank=True), + ), + ] diff --git a/taiga/projects/userstories/migrations/0012_auto_20160614_1201.py b/taiga/projects/userstories/migrations/0012_auto_20160614_1201.py new file mode 100644 index 000000000..2150d972d --- /dev/null +++ b/taiga/projects/userstories/migrations/0012_auto_20160614_1201.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0011_userstory_tribe_gig'), + ] + + operations = [ + migrations.AlterField( + model_name='userstory', + name='external_reference', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=False, null=False), blank=True, default=None, null=True, size=None, verbose_name='external reference'), + ), + migrations.AlterField( + model_name='userstory', + name='tags', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), blank=True, default=list, null=True, size=None, verbose_name='tags'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py b/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py new file mode 100644 index 000000000..7a8be3f9b --- /dev/null +++ b/taiga/projects/userstories/migrations/0013_auto_20160722_1018.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-07-22 10:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0012_auto_20160614_1201'), + ] + + operations = [ + migrations.AlterField( + model_name='userstory', + name='kanban_order', + field=models.IntegerField(default=10000, verbose_name='kanban order'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0014_auto_20160928_0540.py b/taiga/projects/userstories/migrations/0014_auto_20160928_0540.py new file mode 100644 index 000000000..dfb308997 --- /dev/null +++ b/taiga/projects/userstories/migrations/0014_auto_20160928_0540.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-09-28 05:40 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.base.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0013_auto_20160722_1018'), + ] + + operations = [ + migrations.AlterField( + model_name='userstory', + name='backlog_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='backlog order'), + ), + migrations.AlterField( + model_name='userstory', + name='kanban_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='kanban order'), + ), + migrations.AlterField( + model_name='userstory', + name='sprint_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='sprint order'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0015_add_due_date.py b/taiga/projects/userstories/migrations/0015_add_due_date.py new file mode 100644 index 000000000..9111472f1 --- /dev/null +++ b/taiga/projects/userstories/migrations/0015_add_due_date.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.2 on 2018-04-09 09:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0014_auto_20160928_0540'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='due_date', + field=models.DateField(blank=True, default=None, null=True, verbose_name='due date'), + ), + migrations.AddField( + model_name='userstory', + name='due_date_reason', + field=models.TextField(blank=True, default='', verbose_name='reason for the due date'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0016_userstory_assigned_users.py b/taiga/projects/userstories/migrations/0016_userstory_assigned_users.py new file mode 100644 index 000000000..46af7a5fa --- /dev/null +++ b/taiga/projects/userstories/migrations/0016_userstory_assigned_users.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.2 on 2018-02-13 10:14 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('userstories', '0015_add_due_date'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='assigned_users', + field=models.ManyToManyField(blank=True, default=None, related_name='assigned_userstories', to=settings.AUTH_USER_MODEL, verbose_name='assigned users'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0017_userstory_generated_from_task.py b/taiga/projects/userstories/migrations/0017_userstory_generated_from_task.py new file mode 100644 index 000000000..a5bb912a5 --- /dev/null +++ b/taiga/projects/userstories/migrations/0017_userstory_generated_from_task.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.16 on 2019-02-19 13:45 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0012_add_due_date'), + ('userstories', '0016_userstory_assigned_users'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='generated_from_task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generated_user_stories', to='tasks.Task', verbose_name='generated from task'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0018_auto_20200615_0811.py b/taiga/projects/userstories/migrations/0018_auto_20200615_0811.py new file mode 100644 index 000000000..027dcf443 --- /dev/null +++ b/taiga/projects/userstories/migrations/0018_auto_20200615_0811.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-15 08:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0017_userstory_generated_from_task'), + ] + + operations = [ + migrations.AlterField( + model_name='userstory', + name='client_requirement', + field=models.BooleanField(blank=True, default=False, verbose_name='is client requirement'), + ), + migrations.AlterField( + model_name='userstory', + name='is_blocked', + field=models.BooleanField(blank=True, default=False, verbose_name='is blocked'), + ), + migrations.AlterField( + model_name='userstory', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_user_stories', to=settings.AUTH_USER_MODEL, verbose_name='owner'), + ), + migrations.AlterField( + model_name='userstory', + name='team_requirement', + field=models.BooleanField(blank=True, default=False, verbose_name='is team requirement'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0019_userstory_from_task_ref.py b/taiga/projects/userstories/migrations/0019_userstory_from_task_ref.py new file mode 100644 index 000000000..f8af9659c --- /dev/null +++ b/taiga/projects/userstories/migrations/0019_userstory_from_task_ref.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-17 10:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0018_auto_20200615_0811'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='from_task_ref', + field=models.TextField(blank=True, null=True, verbose_name='reference from task'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0020_userstory_swimlane.py b/taiga/projects/userstories/migrations/0020_userstory_swimlane.py new file mode 100644 index 000000000..354b98310 --- /dev/null +++ b/taiga/projects/userstories/migrations/0020_userstory_swimlane.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.15 on 2020-10-07 16:04 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0064_swimlane'), + ('userstories', '0019_userstory_from_task_ref'), + ] + + operations = [ + migrations.AddField( + model_name='userstory', + name='swimlane', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_stories', to='projects.Swimlane', verbose_name='swimlane'), + ), + ] diff --git a/taiga/projects/userstories/migrations/0021_auto_20201202_0850.py b/taiga/projects/userstories/migrations/0021_auto_20201202_0850.py new file mode 100644 index 000000000..1c7ea7bf7 --- /dev/null +++ b/taiga/projects/userstories/migrations/0021_auto_20201202_0850.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.14 on 2020-12-02 08:50 + +from django.db import migrations, models +import taiga.base.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0020_userstory_swimlane'), + ] + + operations = [ + migrations.AlterField( + model_name='userstory', + name='backlog_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_mics, verbose_name='backlog order'), + ), + migrations.AlterField( + model_name='userstory', + name='kanban_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_mics, verbose_name='kanban order'), + ), + migrations.AlterField( + model_name='userstory', + name='sprint_order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_mics, verbose_name='sprint order'), + ), + ] diff --git a/taiga/projects/userstories/migrations/__init__.py b/taiga/projects/userstories/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/userstories/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py new file mode 100644 index 000000000..08b1eca21 --- /dev/null +++ b/taiga/projects/userstories/models.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone + +from picklefield.fields import PickledObjectField + +from taiga.base.utils.time import timestamp_mics +from taiga.projects.due_dates.models import DueDateMixin +from taiga.projects.tagging.models import TaggedMixin +from taiga.projects.occ import OCCModelMixin +from taiga.projects.notifications.mixins import WatchedModelMixin +from taiga.projects.mixins.blocked import BlockedMixin + + +class RolePoints(models.Model): + user_story = models.ForeignKey( + "UserStory", + null=False, + blank=False, + related_name="role_points", + verbose_name=_("user story"), + on_delete=models.CASCADE, + ) + role = models.ForeignKey( + "users.Role", + null=False, + blank=False, + related_name="role_points", + verbose_name=_("role"), + on_delete=models.CASCADE, + ) + points = models.ForeignKey( + "projects.Points", + null=True, + blank=False, + related_name="role_points", + verbose_name=_("points"), + on_delete=models.CASCADE, + ) + + class Meta: + verbose_name = "role points" + verbose_name_plural = "role points" + unique_together = ("user_story", "role") + ordering = ["user_story", "role"] + + def __str__(self): + return "{}: {}".format(self.role.name, self.points.name) + + @property + def project(self): + return self.user_story.project + + +class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, DueDateMixin, models.Model): + NEW_BACKLOG_ORDER = timestamp_mics + NEW_SPRINT_ORDER = timestamp_mics + NEW_KANBAN_ORDER = timestamp_mics + + ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, + verbose_name=_("ref")) + milestone = models.ForeignKey("milestones.Milestone", null=True, blank=True, + default=None, related_name="user_stories", + on_delete=models.SET_NULL, verbose_name=_("milestone")) + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="user_stories", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, + related_name="owned_user_stories", verbose_name=_("owner"), + on_delete=models.SET_NULL) + status = models.ForeignKey("projects.UserStoryStatus", null=True, blank=True, + related_name="user_stories", verbose_name=_("status"), + on_delete=models.SET_NULL) + is_closed = models.BooleanField(default=False) + points = models.ManyToManyField("projects.Points", blank=False, + related_name="userstories", through="RolePoints", + verbose_name=_("points")) + + backlog_order = models.BigIntegerField(null=False, blank=False, default=NEW_BACKLOG_ORDER, + verbose_name=_("backlog order")) + sprint_order = models.BigIntegerField(null=False, blank=False, default=NEW_SPRINT_ORDER, + verbose_name=_("sprint order")) + kanban_order = models.BigIntegerField(null=False, blank=False, default=NEW_KANBAN_ORDER, + verbose_name=_("kanban order")) + + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + finish_date = models.DateTimeField(null=True, blank=True, + verbose_name=_("finish date")) + subject = models.TextField(null=False, blank=False, + verbose_name=_("subject")) + description = models.TextField(null=False, blank=True, verbose_name=_("description")) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=True, + null=True, + default=None, + related_name="userstories_assigned_to_me", + verbose_name=_("assigned to"), + on_delete=models.SET_NULL, + ) + assigned_users = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, + default=None, related_name="assigned_userstories", + verbose_name=_("assigned users")) + client_requirement = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is client requirement")) + team_requirement = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is team requirement")) + attachments = GenericRelation("attachments.Attachment") + generated_from_issue = models.ForeignKey("issues.Issue", null=True, blank=True, + on_delete=models.SET_NULL, + related_name="generated_user_stories", + verbose_name=_("generated from issue")) + generated_from_task = models.ForeignKey("tasks.Task", null=True, blank=True, + on_delete=models.SET_NULL, + related_name="generated_user_stories", + verbose_name=_("generated from task")) + from_task_ref = models.TextField(null=True, blank=True, verbose_name=_("reference from task")) + external_reference = ArrayField(models.TextField(null=False, blank=False), + null=True, blank=True, default=None, verbose_name=_("external reference")) + + tribe_gig = PickledObjectField(null=True, blank=True, default=None, + verbose_name="taiga tribe gig") + + swimlane = models.ForeignKey("projects.Swimlane", null=True, blank=True, + related_name="user_stories", verbose_name=_("swimlane"), + on_delete=models.SET_NULL) + + _importing = None + + class Meta: + verbose_name = "user story" + verbose_name_plural = "user stories" + ordering = ["project", "backlog_order", "ref"] + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + if not self.status: + self.status = self.project.default_us_status + + super().save(*args, **kwargs) + + if not self.role_points.all(): + for role in self.get_roles(): + RolePoints.objects.create(role=role, + points=self.project.default_points, + user_story=self) + + def __str__(self): + return "({1}) {0}".format(self.ref, self.subject) + + def __repr__(self): + return "" % (self.id) + + def get_role_points(self): + return self.role_points + + def get_total_points(self): + not_null_role_points = [ + rp.points.value + for rp in self.role_points.all() + if rp.points.value is not None + ] + + # If we only have None values the sum should be None + if not not_null_role_points: + return None + + return sum(not_null_role_points) + + def get_roles(self): + return self.project.roles.filter(computable=True).all() diff --git a/taiga/projects/userstories/permissions.py b/taiga/projects/userstories/permissions.py new file mode 100644 index 000000000..2932727d6 --- /dev/null +++ b/taiga/projects/userstories/permissions.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsAuthenticated, IsSuperUser +from taiga.permissions.permissions import HasProjectPerm, IsProjectAdmin + +from taiga.permissions.permissions import CommentAndOrUpdatePerm + + +class UserStoryPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + by_ref_perms = HasProjectPerm('view_us') + create_perms = HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us') + update_perms = CommentAndOrUpdatePerm('modify_us', 'comment_us') + partial_update_perms = CommentAndOrUpdatePerm('modify_us', 'comment_us') + destroy_perms = HasProjectPerm('delete_us') + list_perms = AllowAny() + filters_data_perms = AllowAny() + csv_perms = AllowAny() + bulk_create_perms = IsAuthenticated() & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us')) + bulk_update_order_perms = HasProjectPerm('modify_us') + bulk_update_milestone_perms = HasProjectPerm('modify_us') + upvote_perms = IsAuthenticated() & HasProjectPerm('view_us') + downvote_perms = IsAuthenticated() & HasProjectPerm('view_us') + watch_perms = IsAuthenticated() & HasProjectPerm('view_us') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_us') + + +class UserStoryVotersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + list_perms = HasProjectPerm('view_us') + + +class UserStoryWatchersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + list_perms = HasProjectPerm('view_us') diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py new file mode 100644 index 000000000..e79819591 --- /dev/null +++ b/taiga/projects/userstories/serializers.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.base.neighbors import NeighborsSerializerMixin + +from taiga.mdrender.service import render as mdrender +from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.due_dates.serializers import DueDateSerializerMixin +from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.projects.tagging.serializers import TaggedInProjectResourceSerializer +from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin +from taiga.projects.history.mixins import TotalCommentsSerializerMixin + + +class OriginItemSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + + def to_value(self, instance): + if instance is None: + return None + + return super().to_value(instance) + + +class UserStoryListSerializer(ProjectExtraInfoSerializerMixin, + VoteResourceSerializerMixin, WatchedResourceSerializer, + OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, + TaggedInProjectResourceSerializer, TotalCommentsSerializerMixin, + DueDateSerializerMixin, serializers.LightSerializer): + + id = Field() + ref = Field() + milestone = Field(attr="milestone_id") + milestone_slug = MethodField() + milestone_name = MethodField() + project = Field(attr="project_id") + is_closed = Field() + points = MethodField() + backlog_order = Field() + sprint_order = Field() + kanban_order = Field() + created_date = Field() + modified_date = Field() + finish_date = Field() + subject = Field() + client_requirement = Field() + team_requirement = Field() + generated_from_issue = Field(attr="generated_from_issue_id") + generated_from_task = Field(attr="generated_from_task_id") + from_task_ref = Field() + external_reference = Field() + tribe_gig = Field() + version = Field() + watchers = Field() + is_blocked = Field() + blocked_note = Field() + total_points = MethodField() + comment = MethodField() + origin_issue = OriginItemSerializer(attr="generated_from_issue") + origin_task = OriginItemSerializer(attr="generated_from_task") + epics = MethodField() + epic_order = MethodField() + tasks = MethodField() + total_attachments = Field() + swimlane = Field(attr="swimlane_id") + + assigned_users = MethodField() + + def get_assigned_users(self, obj): + """Get the assigned of an object. + + :return: User queryset object representing the assigned users + """ + if not obj.assigned_to: + return set([user.id for user in obj.assigned_users.all()]) + + assigned_users = [user.id for user in obj.assigned_users.all()] + \ + [obj.assigned_to.id] + + if not assigned_users: + return None + + return set(assigned_users) + + def get_epic_order(self, obj): + include_epic_order = getattr(obj, "include_epic_order", False) + + if include_epic_order: + assert hasattr(obj, "epic_order"), "instance must have a epic_order attribute" + + if not include_epic_order or obj.epic_order is None: + return None + + return obj.epic_order + + def get_epics(self, obj): + assert hasattr(obj, "epics_attr"), "instance must have a epics_attr attribute" + return obj.epics_attr + + def get_milestone_slug(self, obj): + return obj.milestone.slug if obj.milestone else None + + def get_milestone_name(self, obj): + return obj.milestone.name if obj.milestone else None + + def get_total_points(self, obj): + assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute" + return obj.total_points_attr + + def get_points(self, obj): + assert hasattr(obj, "role_points_attr"), "instance must have a role_points_attr attribute" + if obj.role_points_attr is None: + return {} + + return obj.role_points_attr + + def get_comment(self, obj): + return "" + + def get_tasks(self, obj): + include_tasks = getattr(obj, "include_tasks", False) + + if include_tasks: + assert hasattr(obj, "tasks_attr"), "instance must have a tasks_attr attribute" + + if not include_tasks or obj.tasks_attr is None: + return [] + + return obj.tasks_attr + + +class UserStorySerializer(UserStoryListSerializer): + comment = MethodField() + blocked_note_html = MethodField() + description = Field() + description_html = MethodField() + + def get_comment(self, obj): + # NOTE: This method and field is necessary to historical comments work + return "" + + def get_blocked_note_html(self, obj): + return mdrender(obj.project, obj.blocked_note) + + def get_description_html(self, obj): + return mdrender(obj.project, obj.description) + + +class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer): + pass + + +class UserStoryLightSerializer(ProjectExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, + AssignedToExtraInfoSerializerMixin, + DueDateSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + milestone = Field(attr="milestone_id") + project = Field(attr="project_id") + is_closed = Field() + created_date = Field() + modified_date = Field() + finish_date = Field() + subject = Field() + client_requirement = Field() + team_requirement = Field() + external_reference = Field() + version = Field() + is_blocked = Field() + blocked_note = Field() + + +class UserStoryOnlyRefSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + + +class UserStoryNestedSerializer(ProjectExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, + AssignedToExtraInfoSerializerMixin, + DueDateSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + milestone = Field(attr="milestone_id") + project = Field(attr="project_id") + is_closed = Field() + created_date = Field() + modified_date = Field() + finish_date = Field() + subject = Field() + client_requirement = Field() + team_requirement = Field() + external_reference = Field() + version = Field() + is_blocked = Field() + blocked_note = Field() + backlog_order = Field() + sprint_order = Field() + kanban_order = Field() + + epics = MethodField() + points = MethodField() + total_points = MethodField() + + def get_epics(self, obj): + assert hasattr(obj, "epics_attr"), "instance must have a epics_attr attribute" + return obj.epics_attr + + def get_points(self, obj): + assert hasattr(obj, "role_points_attr"), "instance must have a role_points_attr attribute" + if obj.role_points_attr is None: + return {} + + return obj.role_points_attr + + def get_total_points(self, obj): + assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute" + return obj.total_points_attr diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py new file mode 100644 index 000000000..7267d3c70 --- /dev/null +++ b/taiga/projects/userstories/services.py @@ -0,0 +1,1140 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from typing import List, Optional + +import csv +import io +from collections import OrderedDict +from operator import itemgetter +from contextlib import closing + +from django.conf import settings +from django.db import connection +from django.db.models import Q +from django.utils import timezone +from django.utils.translation import gettext as _ + +from psycopg2.extras import execute_values + +from taiga.base.utils import db, text +from taiga.celery import app +from taiga.events import events +from taiga.projects.history.services import take_snapshot +from taiga.projects.models import Project, UserStoryStatus, Swimlane +from taiga.projects.milestones.models import Milestone +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.services import apply_order_updates +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.apps import connect_userstories_signals +from taiga.projects.userstories.apps import disconnect_userstories_signals +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.users.models import User +from taiga.users.gravatar import get_gravatar_id +from taiga.users.services import get_big_photo_url, get_photo_url + +from . import models + + +##################################################### +# Bulk actions +##################################################### + +def get_userstories_from_bulk(bulk_data, **additional_fields): + """Convert `bulk_data` into a list of user stories. + + :param bulk_data: List of user stories in bulk format. + :param additional_fields: Additional fields when instantiating each user + story. + + :return: List of `UserStory` instances. + """ + return [models.UserStory(subject=line, **additional_fields) + for line in text.split_in_lines(bulk_data)] + + +def create_userstories_in_bulk(bulk_data, callback=None, precall=None, + **additional_fields): + """Create user stories from `bulk_data`. + + :param bulk_data: List of user stories in bulk format. + :param callback: Callback to execute after each user story save. + :param additional_fields: Additional fields when instantiating each user + story. + + :return: List of created `Task` instances. + """ + userstories = get_userstories_from_bulk(bulk_data, **additional_fields) + project = additional_fields.get("project") + disconnect_userstories_signals() + + try: + db.save_in_bulk(userstories, callback, precall) + project.update_role_points(user_stories=userstories) + finally: + connect_userstories_signals() + + return userstories + + +def update_userstories_order_in_bulk(bulk_data: list, field: str, + project: object, + status: object = None, + milestone: object = None): + """ + Updates the order of the userstories specified adding the extra updates + needed to keep consistency. + `bulk_data` should be a list of dicts with the following format: + `field` is the order field used + + [{'us_id': , 'order': }, ...] + """ + user_stories = project.user_stories.all() + if status is not None: + user_stories = user_stories.filter(status=status) + if milestone is not None: + user_stories = user_stories.filter(milestone=milestone) + + us_orders = {us.id: getattr(us, field) for us in user_stories} + new_us_orders = {e["us_id"]: e["order"] for e in bulk_data} + apply_order_updates(us_orders, new_us_orders, remove_equal_original=True) + + user_story_ids = us_orders.keys() + events.emit_event_for_ids(ids=user_story_ids, + content_type="userstories.userstory", + projectid=project.pk) + db.update_attr_in_bulk_for_ids(us_orders, field, models.UserStory) + return us_orders + + +def reset_userstories_kanban_order_in_bulk(project: Project, + bulk_userstories: List[int]): + """ + Reset the order of the userstories specified adding the extra updates + needed to keep consistency. + + - `bulk_userstories` should be a list of user stories IDs + """ + base_order = models.UserStory.NEW_KANBAN_ORDER() + data = ((id, base_order + index) for index, id in enumerate(bulk_userstories)) + + sql = """ + UPDATE userstories_userstory + SET kanban_order = tmp.new_kanban_order::BIGINT + FROM (VALUES %s) AS tmp (id, new_kanban_order) + WHERE tmp.id = userstories_userstory.id + """ + with connection.cursor() as cursor: + execute_values(cursor, sql, data) + + ## Sent events of updated stories + events.emit_event_for_ids(ids=bulk_userstories, + content_type="userstories.userstory", + projectid=project.id) + + +def update_userstories_backlog_or_sprint_order_in_bulk(user: User, + project: Project, + bulk_userstories: List[int], + before_userstory: Optional[models.UserStory] = None, + after_userstory: Optional[models.UserStory] = None, + milestone: Optional[Milestone] = None): + """ + Updates the order of the userstories specified adding the extra updates + needed to keep consistency. + + Note: `after_userstory_id` and `before_userstory_id` are mutually exclusive; + you can use only one at a given request. They can be both None which + means "at the beginning of is cell" + + - `bulk_userstories` should be a list of user stories IDs + """ + # Get ids from milestones affected + milestones_ids = set(project.milestones.filter(user_stories__in=bulk_userstories).values_list('id', flat=True)) + if milestone: + milestones_ids.add(milestone.id) + + order_param = "backlog_order" + + # filter user stories from milestone + user_stories = project.user_stories.all() + if milestone is not None: + user_stories = user_stories.filter(milestone=milestone) + order_param = "sprint_order" + else: + user_stories = user_stories.filter(milestone__isnull=True) + + # exclude moved user stories + user_stories = user_stories.exclude(id__in=bulk_userstories) + + # if before_userstory, get it and all elements before too: + if before_userstory: + user_stories = (user_stories.filter(**{f"{order_param}__gte": getattr(before_userstory, order_param)})) + # if after_userstory, exclude it and get only elements after it: + elif after_userstory: + user_stories = (user_stories.exclude(id=after_userstory.id) + .filter(**{f"{order_param}__gte": getattr(after_userstory, order_param)})) + + # sort and get only ids + user_story_ids = (user_stories.order_by(order_param, "id") + .values_list('id', flat=True)) + + # append moved user stories + user_story_ids = bulk_userstories + list(user_story_ids) + + # calculate the start order + if before_userstory: + # order start with the before_userstory order + start_order = getattr(before_userstory, order_param) + elif after_userstory: + # order start after the after_userstory order + start_order = getattr(after_userstory, order_param) + 1 + else: + # move at the beggining of the column if there is no after and before + start_order = 1 + + # prepare rest of data + total_user_stories = len(user_story_ids) + user_story_orders = range(start_order, start_order + total_user_stories) + + data = tuple(zip(user_story_ids, + user_story_orders)) + + # execute query for update milestone and backlog or sprint order + sql = f""" + UPDATE userstories_userstory + SET {order_param} = tmp.new_order::BIGINT + FROM (VALUES %s) AS tmp (id, new_order) + WHERE tmp.id = userstories_userstory.id + """ + with connection.cursor() as cursor: + execute_values(cursor, sql, data) + + # execute query for update milestone for user stories and its tasks + bulk_userstories_objects = project.user_stories.filter(id__in=bulk_userstories) + bulk_userstories_objects.update(milestone=milestone) + project.tasks.filter(user_story__in=bulk_userstories).update(milestone=milestone) + + # Generate snapshots for user stories and tasks and calculate if aafected milestones + # are cosed or open now. + if settings.CELERY_ENABLED: + _async_tasks_after_backlog_or_sprint_order_change.delay(bulk_userstories, milestones_ids, user.id) + else: + _async_tasks_after_backlog_or_sprint_order_change(bulk_userstories, milestones_ids, user.id) + + # Sent events of updated stories + events.emit_event_for_ids(ids=user_story_ids, + content_type="userstories.userstory", + projectid=project.pk) + + # Generate response with modified info + res = ({ + "id": id, + "milestone": milestone.id if milestone else None, + order_param: order + } for (id, order) in data) + return res + + +@app.task +def _async_tasks_after_backlog_or_sprint_order_change(userstories_ids, milestones_ids, user_id): + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + user = None + + # Take snapshots for user stories and their taks + for userstory in models.UserStory.objects.filter(id__in=userstories_ids): + take_snapshot(userstory, user=user) + + for task in userstory.tasks.all(): + take_snapshot(task, user=user) + + # Check if milestones are open or closed after stories are moved + for milestone in Milestone.objects.filter(id__in=milestones_ids): + _recalculate_is_closed_for_milestone(milestone) + + + +def update_userstories_kanban_order_in_bulk(user: User, + project: Project, + status: UserStoryStatus, + bulk_userstories: List[int], + before_userstory: Optional[models.UserStory] = None, + after_userstory: Optional[models.UserStory] = None, + swimlane: Optional[Swimlane] = None): + """ + Updates the order of the userstories specified adding the extra updates + needed to keep consistency. + + Note: `after_userstory_id` and `before_userstory_id` are mutually exclusive; + you can use only one at a given request. They can be both None which + means "at the beginning of is cell" + + - `bulk_userstories` should be a list of user stories IDs + """ + + + # filter user stories from status and swimlane + user_stories = project.user_stories.filter(status=status) + if swimlane is not None: + user_stories = user_stories.filter(swimlane=swimlane) + else: + user_stories = user_stories.filter(swimlane__isnull=True) + + # exclude moved user stories + user_stories = user_stories.exclude(id__in=bulk_userstories) + + # if before_userstory, get it and all elements before too: + if before_userstory: + user_stories = (user_stories.filter(kanban_order__gte=before_userstory.kanban_order)) + # if after_userstory, exclude it and get only elements after it: + elif after_userstory: + user_stories = (user_stories.exclude(id=after_userstory.id) + .filter(kanban_order__gte=after_userstory.kanban_order)) + + # sort and get only ids + user_story_ids = (user_stories.order_by("kanban_order", "id") + .values_list('id', flat=True)) + + # append moved user stories + user_story_ids = bulk_userstories + list(user_story_ids) + + # calculate the start order + if before_userstory: + # order start with the before_userstory order + start_order = before_userstory.kanban_order + elif after_userstory: + # order start after the after_userstory order + start_order = after_userstory.kanban_order + 1 + else: + # move at the beggining of the column if there is no after and before + start_order = 1 + + # prepare rest of data + total_user_stories = len(user_story_ids) + user_story_kanban_orders = range(start_order, start_order + total_user_stories) + + data = tuple(zip(user_story_ids, + user_story_kanban_orders)) + + # execute query for update kanban_order + sql = """ + UPDATE userstories_userstory + SET kanban_order = tmp.new_kanban_order::BIGINT + FROM (VALUES %s) AS tmp (id, new_kanban_order) + WHERE tmp.id = userstories_userstory.id + """ + with connection.cursor() as cursor: + execute_values(cursor, sql, data) + + # execute query for update status, swimlane and kanban_order + bulk_userstories_objects = project.user_stories.filter(id__in=bulk_userstories) + bulk_userstories_objects.update(status=status, swimlane=swimlane) + + # Update is_closed attr for user stories and related milestones + if settings.CELERY_ENABLED: + _async_tasks_after_kanban_order_change.delay(bulk_userstories, user.id) + else: + _async_tasks_after_kanban_order_change(bulk_userstories, user.id) + + # Sent events of updated stories + events.emit_event_for_ids(ids=user_story_ids, + content_type="userstories.userstory", + projectid=project.pk) + + # Generate response with modified info + res = ({ + "id": id, + "swimlane": swimlane.id if swimlane else None, + "status": status.id, + "kanban_order": kanban_order + } for (id, kanban_order) in data) + return res + + +@app.task +def _async_tasks_after_kanban_order_change(userstories_ids, user_id): + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + user = None + + for userstory in models.UserStory.objects.filter(id__in=userstories_ids): + recalculate_is_closed_for_userstory_and_its_milestone(userstory) + # Generate the history entity + take_snapshot(userstory, user=user) + + +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': , 'order': }, ...] + """ + 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) + + db.update_attr_in_bulk_for_ids(us_milestones, "milestone_id", + model=models.UserStory) + db.update_attr_in_bulk_for_ids(us_orders, "sprint_order", models.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 = models.UserStory.objects.get(pk=us_data['us_id']) + take_snapshot(us, user=user) + except models.UserStory.DoesNotExist: + pass + + +##################################################### +# Open/Close calcs +##################################################### + +def calculate_userstory_is_closed(user_story): + if user_story.status is None: + return False + + if user_story.tasks.count() == 0: + return user_story.status is not None and user_story.status.is_closed + + if all([task.status is not None and task.status.is_closed for task in + user_story.tasks.all()]): + return True + + return False + + +def close_userstory(us): + if not us.is_closed: + us.is_closed = True + us.finish_date = timezone.now() + us.save(update_fields=["is_closed", "finish_date"]) + return True + return False + + +def open_userstory(us): + if us.is_closed: + us.is_closed = False + us.finish_date = None + us.save(update_fields=["is_closed", "finish_date"]) + return True + return False + + + +def recalculate_is_closed_for_userstory_and_its_milestone(userstory): + """ + Check and update the open or closed condition for the userstory and its milestone. + + NOTE: [1] This method is useful if the userstory update operation has been done without + using the ORM (pre_save/post_save model signals). + [2] Do not use it if the milestone has been previously updated. + """ + has_changed = False + # Update is_close attr for the user story + if calculate_userstory_is_closed(userstory): + has_changed = close_userstory(userstory) + else: + has_changed = open_userstory(userstory) + + if has_changed and userstory.milestone_id: + _recalculate_is_closed_for_milestone(userstory.milestone) + + +def _recalculate_is_closed_for_milestone(milestone): + """ + Check and update the open or closed condition for the milestone. + + NOTE: [1] This method is useful if the userstory update operation has been done without + using the ORM (pre_save/post_save model signals). + """ + from taiga.projects.milestones import services as milestone_service + + # Update is_close attr for the milestone + if milestone_service.calculate_milestone_is_closed(milestone): + milestone_service.close_milestone(milestone) + else: + milestone_service.open_milestone(milestone) + +##################################################### +# CSV +##################################################### + +def userstories_to_csv(project, queryset): + csv_data = io.StringIO() + fieldnames = ["id", "ref", "subject", "description", "sprint_id", "sprint", + "sprint_estimated_start", "sprint_estimated_finish", "owner", + "owner_full_name", "assigned_to", "assigned_to_full_name", + "assigned_users", "assigned_users_full_name", "status", + "is_closed", "swimlane"] + + roles = project.roles.filter(computable=True).order_by('slug') + for role in roles: + fieldnames.append("{}-points".format(role.slug)) + + fieldnames.append("total-points") + + fieldnames += ["backlog_order", "sprint_order", "kanban_order", + "created_date", "modified_date", "finish_date", + "client_requirement", "team_requirement", "attachments", + "generated_from_issue", "generated_from_task", "from_task_ref", + "external_reference", "tasks", "tags", "watchers", "voters", + "due_date", "due_date_reason", "epics"] + + custom_attrs = project.userstorycustomattributes.all() + for custom_attr in custom_attrs: + fieldnames.append(custom_attr.name) + + queryset = queryset.prefetch_related("role_points", + "role_points__points", + "role_points__role", + "tasks", + "epics", + "attachments", + "custom_attributes_values") + queryset = queryset.select_related("milestone", + "project", + "status", + "owner", + "assigned_to", + "generated_from_issue", + "generated_from_task") + + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + + writer = csv.DictWriter(csv_data, fieldnames=fieldnames) + writer.writeheader() + for us in queryset: + row = { + "id": us.id, + "ref": us.ref, + "subject": us.subject, + "description": us.description, + "sprint_id": us.milestone.id if us.milestone else None, + "sprint": us.milestone.name if us.milestone else None, + "sprint_estimated_start": us.milestone.estimated_start if + us.milestone else None, + "sprint_estimated_finish": us.milestone.estimated_finish if + us.milestone else None, + "owner": us.owner.username if us.owner else None, + "owner_full_name": us.owner.get_full_name() if us.owner else None, + "assigned_to": us.assigned_to.username if us.assigned_to else None, + "assigned_to_full_name": us.assigned_to.get_full_name() if + us.assigned_to else None, + "assigned_users": ",".join( + [assigned_user.username for assigned_user in + us.assigned_users.all()]), + "assigned_users_full_name": ",".join( + [assigned_user.get_full_name() for assigned_user in + us.assigned_users.all()]), + "status": us.status.name if us.status else None, + "is_closed": us.is_closed, + "swimlane": us.swimlane.name if us.swimlane else None, + "backlog_order": us.backlog_order, + "sprint_order": us.sprint_order, + "kanban_order": us.kanban_order, + "created_date": us.created_date, + "modified_date": us.modified_date, + "finish_date": us.finish_date, + "client_requirement": us.client_requirement, + "team_requirement": us.team_requirement, + "attachments": us.attachments.count(), + "generated_from_issue": us.generated_from_issue.ref if + us.generated_from_issue else None, + "generated_from_task": us.generated_from_task.ref if + us.generated_from_task else None, + "from_task_ref": us.from_task_ref, + "external_reference": us.external_reference, + "tasks": ",".join([str(task.ref) for task in us.tasks.all()]), + "tags": ",".join(us.tags or []), + "watchers": us.watchers, + "voters": us.total_voters, + "due_date": us.due_date, + "due_date_reason": us.due_date_reason, + "epics": ",".join([str(epic.ref) for epic in us.epics.all()]), + } + + us_role_points_by_role_id = {us_rp.role.id: us_rp.points.value for + us_rp in us.role_points.all()} + for role in roles: + row["{}-points".format(role.slug)] = \ + us_role_points_by_role_id.get(role.id, 0) + + row['total-points'] = us.get_total_points() + + for custom_attr in custom_attrs: + if not hasattr(us, "custom_attributes_values"): + continue + value = us.custom_attributes_values.attributes_values.get( + str(custom_attr.id), None) + row[custom_attr.name] = value + + writer.writerow(row) + + return csv_data + + +##################################################### +# Api filter data +##################################################### + +def _get_userstories_statuses(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH "us_counters" AS ( + SELECT DISTINCT "userstories_userstory"."status_id" "status_id", + "userstories_userstory"."id" "us_id" + FROM "userstories_userstory" + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + LEFT OUTER JOIN "epics_relateduserstory" + ON "userstories_userstory"."id" = "epics_relateduserstory"."user_story_id" + LEFT OUTER JOIN "userstories_userstory_assigned_users" + ON "userstories_userstory"."id" = "userstories_userstory_assigned_users"."userstory_id" + WHERE {where} + ), + "counters" AS ( + SELECT "status_id", + COUNT("status_id") "count" + FROM "us_counters" + GROUP BY "status_id" + ) + + SELECT "projects_userstorystatus"."id", + "projects_userstorystatus"."name", + "projects_userstorystatus"."color", + "projects_userstorystatus"."order", + COALESCE("counters"."count", 0) + FROM "projects_userstorystatus" + LEFT OUTER JOIN "counters" + ON "counters"."status_id" = "projects_userstorystatus"."id" + WHERE "projects_userstorystatus"."project_id" = %s + ORDER BY "projects_userstorystatus"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, color, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": color, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def _get_userstories_assigned_to(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH "us_counters" AS ( + SELECT DISTINCT "userstories_userstory"."assigned_to_id" "assigned_to_id", + "userstories_userstory"."id" "us_id" + FROM "userstories_userstory" + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + INNER JOIN "projects_userstorystatus" + ON ("userstories_userstory"."status_id" = "projects_userstorystatus"."id") + LEFT OUTER JOIN "epics_relateduserstory" + ON "userstories_userstory"."id" = "epics_relateduserstory"."user_story_id" + LEFT OUTER JOIN "userstories_userstory_assigned_users" + ON "userstories_userstory"."id" = "userstories_userstory_assigned_users"."userstory_id" + WHERE {where} + ), + + "counters" AS ( + SELECT "assigned_to_id", + COUNT("assigned_to_id") + FROM "us_counters" + GROUP BY "assigned_to_id" + ) + + SELECT "projects_membership"."user_id" "user_id", + "users_user"."full_name" "full_name", + "users_user"."username" "username", + COALESCE("counters".count, 0) "count" + FROM "projects_membership" + LEFT OUTER JOIN "counters" + ON ("projects_membership"."user_id" = "counters"."assigned_to_id") + INNER JOIN "users_user" + ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- unassigned userstories + UNION + + SELECT NULL "user_id", + NULL "full_name", + NULL "username", + count(coalesce("assigned_to_id", -1)) "count" + FROM "userstories_userstory" + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + INNER JOIN "projects_userstorystatus" + ON ("userstories_userstory"."status_id" = "projects_userstorystatus"."id") + LEFT OUTER JOIN "epics_relateduserstory" + ON ("userstories_userstory"."id" = "epics_relateduserstory"."user_story_id") + LEFT OUTER JOIN "userstories_userstory_assigned_users" + ON "userstories_userstory"."id" = "userstories_userstory_assigned_users"."userstory_id" + WHERE {where} AND "userstories_userstory"."assigned_to_id" IS NULL + GROUP BY "assigned_to_id" + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id] + where_params) + rows = cursor.fetchall() + + result = [] + none_valued_added = False + for id, full_name, username, count in rows: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + }) + + if id is None: + none_valued_added = True + + # If there was no userstory with null assigned_to we manually add it + if not none_valued_added: + result.append({ + "id": None, + "full_name": "", + "count": 0, + }) + + return sorted(result, key=itemgetter("full_name")) + + +def _get_userstories_assigned_users(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH "us_counters" AS ( + SELECT DISTINCT COALESCE("userstories_userstory_assigned_users"."user_id", + "userstories_userstory"."assigned_to_id") as "assigned_user_id", + "userstories_userstory"."id" "us_id" + FROM "userstories_userstory" + LEFT JOIN "userstories_userstory_assigned_users" + ON "userstories_userstory_assigned_users"."userstory_id" = "userstories_userstory"."id" + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + INNER JOIN "projects_userstorystatus" + ON ("userstories_userstory"."status_id" = "projects_userstorystatus"."id") + LEFT OUTER JOIN "epics_relateduserstory" + ON "userstories_userstory"."id" = "epics_relateduserstory"."user_story_id" + WHERE {where} + ), + + "counters" AS ( + SELECT "assigned_user_id", + COUNT("assigned_user_id") + FROM "us_counters" + GROUP BY "assigned_user_id" + ) + + SELECT "projects_membership"."user_id" "user_id", + "users_user"."full_name" "full_name", + "users_user"."username" "username", + COALESCE("counters".count, 0) "count", + "users_user"."photo" "photo", + "users_user"."email" "email" + FROM "projects_membership" + LEFT OUTER JOIN "counters" + ON ("projects_membership"."user_id" = "counters"."assigned_user_id") + INNER JOIN "users_user" + ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- unassigned userstories + UNION + + SELECT NULL "user_id", + NULL "full_name", + NULL "username", + count(coalesce("assigned_to_id", -1)) "count", + NULL "photo", + NULL "email" + FROM "userstories_userstory" + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + INNER JOIN "projects_userstorystatus" + ON ("userstories_userstory"."status_id" = "projects_userstorystatus"."id") + LEFT OUTER JOIN "epics_relateduserstory" + ON ("userstories_userstory"."id" = "epics_relateduserstory"."user_story_id") + WHERE {where} AND "userstories_userstory"."id" NOT IN ( + SELECT "userstories_userstory_assigned_users"."userstory_id" FROM + "userstories_userstory_assigned_users" + WHERE "userstories_userstory_assigned_users"."userstory_id" = "userstories_userstory"."id" + ) AND "userstories_userstory"."assigned_to_id" IS NULL + GROUP BY "username"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id] + where_params) + rows = cursor.fetchall() + + result = [] + none_valued_added = False + for id, full_name, username, count, photo, email in rows: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + "photo": get_photo_url(photo), + "big_photo": get_big_photo_url(photo), + "gravatar_id": get_gravatar_id(email) if email else None + }) + + if id is None: + none_valued_added = True + + # If there was no userstory with null assigned_to we manually add it + if not none_valued_added: + result.append({ + "id": None, + "full_name": "", + "count": 0, + "photo": None, + "big_photo": None, + "gravatar_id": None + }) + + return sorted(result, key=itemgetter("full_name")) + + +def _get_userstories_owners(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH "us_counters" AS( + SELECT DISTINCT "userstories_userstory"."owner_id" "owner_id", + "userstories_userstory"."id" "us_id" + FROM "userstories_userstory" + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + INNER JOIN "projects_userstorystatus" + ON ("userstories_userstory"."status_id" = "projects_userstorystatus"."id") + LEFT OUTER JOIN "epics_relateduserstory" + ON ("userstories_userstory"."id" = "epics_relateduserstory"."user_story_id") + LEFT OUTER JOIN "userstories_userstory_assigned_users" + ON "userstories_userstory"."id" = "userstories_userstory_assigned_users"."userstory_id" + WHERE {where} + ), + + "counters" AS ( + SELECT "owner_id", + COUNT("owner_id") + FROM "us_counters" + GROUP BY "owner_id" + ) + + SELECT "projects_membership"."user_id" "user_id", + "users_user"."full_name", + "users_user"."username", + COALESCE("counters".count, 0) "count", + "users_user"."photo" "photo", + "users_user"."email" "email" + FROM "projects_membership" + LEFT OUTER JOIN "counters" + ON ("projects_membership"."user_id" = "counters"."owner_id") + INNER JOIN "users_user" + ON ("projects_membership"."user_id" = "users_user"."id") + WHERE "projects_membership"."project_id" = %s AND "projects_membership"."user_id" IS NOT NULL + + -- System users + UNION + + SELECT "users_user"."id" "user_id", + "users_user"."full_name" "full_name", + "users_user"."username" "username", + COALESCE("counters"."count", 0) "count", + NULL "photo", + NULL "email" + FROM "users_user" + LEFT OUTER JOIN "counters" + ON ("users_user"."id" = "counters"."owner_id") + WHERE ("users_user"."is_system" IS TRUE) + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, full_name, username, count, photo, email in rows: + if count > 0: + result.append({ + "id": id, + "full_name": full_name or username or "", + "count": count, + "photo": get_photo_url(photo), + "big_photo": get_big_photo_url(photo), + "gravatar_id": get_gravatar_id(email) if email else None + }) + return sorted(result, key=itemgetter("full_name")) + + +def _get_userstories_tags(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH "userstories_tags" AS ( + SELECT "tag", + COUNT("tag") "counter" + FROM ( + SELECT DISTINCT "userstories_userstory"."id" "us_id", + UNNEST("userstories_userstory"."tags") "tag" + FROM "userstories_userstory" + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + INNER JOIN "projects_userstorystatus" + ON ("userstories_userstory"."status_id" = "projects_userstorystatus"."id") + LEFT OUTER JOIN "epics_relateduserstory" + ON ("userstories_userstory"."id" = "epics_relateduserstory"."user_story_id") + LEFT OUTER JOIN "userstories_userstory_assigned_users" + ON "userstories_userstory"."id" = "userstories_userstory_assigned_users"."userstory_id" + WHERE {where} + ) "tags" + GROUP BY "tag"), + + "project_tags" AS ( + SELECT reduce_dim("tags_colors") "tag_color" + FROM "projects_project" + WHERE "id"=%s) + + SELECT "tag_color"[1] "tag", + "tag_color"[2] "color", + COALESCE("userstories_tags"."counter", 0) "counter" + FROM "project_tags" +LEFT OUTER JOIN "userstories_tags" + ON "project_tags"."tag_color"[1] = "userstories_tags"."tag" + ORDER BY "tag" + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for name, color, count in rows: + result.append({ + "name": name, + "color": color, + "count": count, + }) + return sorted(result, key=itemgetter("name")) + + +def _get_userstories_epics(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + extra_sql = """ + WITH "counters" AS ( + SELECT "epics_relateduserstory"."epic_id" AS "epic_id", + count("epics_relateduserstory"."id") AS "counter" + FROM "epics_relateduserstory" + INNER JOIN "userstories_userstory" + ON ("userstories_userstory"."id" = "epics_relateduserstory"."user_story_id") + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + INNER JOIN "projects_userstorystatus" + ON ("userstories_userstory"."status_id" = "projects_userstorystatus"."id") + LEFT OUTER JOIN "userstories_userstory_assigned_users" + ON "userstories_userstory"."id" = "userstories_userstory_assigned_users"."userstory_id" + WHERE {where} + GROUP BY "epics_relateduserstory"."epic_id" + ) + + -- User stories with no epics (return results only if there are userstories) + SELECT NULL AS "id", + NULL AS "ref", + NULL AS "subject", + 0 AS "order", + count(COALESCE("epics_relateduserstory"."epic_id", -1)) AS "counter" + FROM "userstories_userstory" + LEFT OUTER JOIN "epics_relateduserstory" + ON ("epics_relateduserstory"."user_story_id" = "userstories_userstory"."id") + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + INNER JOIN "projects_userstorystatus" + ON ("userstories_userstory"."status_id" = "projects_userstorystatus"."id") + LEFT OUTER JOIN "userstories_userstory_assigned_users" + ON "userstories_userstory"."id" = "userstories_userstory_assigned_users"."userstory_id" + WHERE {where} AND "epics_relateduserstory"."epic_id" IS NULL + GROUP BY "epics_relateduserstory"."epic_id" + + UNION + + SELECT "epics_epic"."id" AS "id", + "epics_epic"."ref" AS "ref", + "epics_epic"."subject" AS "subject", + "epics_epic"."epics_order" AS "order", + COALESCE("counters"."counter", 0) AS "counter" + FROM "epics_epic" + LEFT OUTER JOIN "counters" + ON ("counters"."epic_id" = "epics_epic"."id") + WHERE "epics_epic"."project_id" = %s + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, ref, subject, order, count in rows: + result.append({ + "id": id, + "ref": ref, + "subject": subject, + "order": order, + "count": count, + }) + + result = sorted(result, key=lambda k: (k["order"], k["id"] or 0)) + + # Add row when there is no user stories with no epics + if result == [] or result[0]["id"] is not None: + result.insert(0, { + "id": None, + "ref": None, + "subject": None, + "order": 0, + "count": 0, + }) + return result + + +def _get_userstories_roles(project, queryset): + compiler = connection.ops.compiler(queryset.query.compiler)(queryset.query, connection, None) + queryset_where_tuple = queryset.query.where.as_sql(compiler, connection) + where = queryset_where_tuple[0] + where_params = queryset_where_tuple[1] + + extra_sql = """ + WITH "us_counters" AS ( + SELECT DISTINCT "userstories_userstory"."status_id" "status_id", + "userstories_userstory"."id" "us_id", + "projects_membership"."role_id" "role_id" + FROM "userstories_userstory" + INNER JOIN "projects_project" + ON ("userstories_userstory"."project_id" = "projects_project"."id") + INNER JOIN "projects_userstorystatus" + ON ("userstories_userstory"."status_id" = "projects_userstorystatus"."id") + LEFT OUTER JOIN "epics_relateduserstory" + ON "userstories_userstory"."id" = "epics_relateduserstory"."user_story_id" + LEFT OUTER JOIN "userstories_userstory_assigned_users" + ON "userstories_userstory_assigned_users"."userstory_id" = "userstories_userstory"."id" + LEFT OUTER JOIN "projects_membership" + ON "projects_membership"."user_id" = "userstories_userstory"."assigned_to_id" + OR "projects_membership"."user_id" = "userstories_userstory_assigned_users"."user_id" + WHERE {where} + ), + "counters" AS ( + SELECT "role_id" as "role_id", + COUNT("role_id") "count" + FROM "us_counters" + GROUP BY "role_id" + ) + + SELECT "users_role"."id", + "users_role"."name", + "users_role"."order", + COALESCE("counters"."count", 0) + FROM "users_role" + LEFT OUTER JOIN "counters" + ON "counters"."role_id" = "users_role"."id" + WHERE "users_role"."project_id" = %s + ORDER BY "users_role"."order"; + """.format(where=where) + + with closing(connection.cursor()) as cursor: + cursor.execute(extra_sql, where_params + [project.id]) + rows = cursor.fetchall() + + result = [] + for id, name, order, count in rows: + result.append({ + "id": id, + "name": _(name), + "color": None, + "order": order, + "count": count, + }) + return sorted(result, key=itemgetter("order")) + + +def get_userstories_filters_data(project, querysets): + """ + Given a project and an userstories queryset, return a simple data structure + of all possible filters for the userstories in the queryset. + """ + data = OrderedDict([ + ("statuses", _get_userstories_statuses(project, querysets["statuses"])), + ("assigned_to", + _get_userstories_assigned_to(project, querysets["assigned_to"])), + ("assigned_users", + _get_userstories_assigned_users(project, querysets["assigned_users"])), + ("owners", _get_userstories_owners(project, querysets["owners"])), + ("tags", _get_userstories_tags(project, querysets["tags"])), + ("epics", _get_userstories_epics(project, querysets["epics"])), + ("roles", _get_userstories_roles(project, querysets["roles"])), + ]) + + return data diff --git a/taiga/projects/userstories/signals.py b/taiga/projects/userstories/signals.py new file mode 100644 index 000000000..438ea11b4 --- /dev/null +++ b/taiga/projects/userstories/signals.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from contextlib import suppress +from django.core.exceptions import ObjectDoesNotExist +from taiga.projects.history.services import take_snapshot +from taiga.projects.tasks.apps import connect_all_tasks_signals, disconnect_all_tasks_signals + + +# Enable tasks signals +def enable_tasks_signals(sender, instance, **kwargs): + connect_all_tasks_signals() + + +# Disable tasks signals +def disable_task_signals(sender, instance, **kwargs): + disconnect_all_tasks_signals() + +#################################### +# Signals for cached prev US +#################################### + +# Define the previous version of the US for use it on the post_save handler +def cached_prev_us(sender, instance, **kwargs): + instance.prev = None + if instance.id: + instance.prev = sender.objects.get(id=instance.id) + + +#################################### +# Signals of role points +#################################### + +def update_role_points_when_create_or_edit_us(sender, instance, **kwargs): + if instance._importing: + return + + instance.project.update_role_points(user_stories=[instance]) + + +#################################### +# Signals for update milestone of tasks +#################################### + +def update_milestone_of_tasks_when_edit_us(sender, instance, created, **kwargs): + if not created: + tasks = instance.tasks.exclude(milestone=instance.milestone) + tasks.update(milestone=instance.milestone) + for task in tasks: + take_snapshot(task) + + +#################################### +# Signals for close US and Milestone +#################################### + +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) + +def try_to_close_milestone_when_delete_us(sender, instance, **kwargs): + if instance._importing: + return + + _try_to_close_milestone_when_delete_us(instance) + + +# US +def _try_to_close_or_open_us_when_create_or_edit_us(instance): + if instance._importing: + return + + from . import services as us_service + + if us_service.calculate_userstory_is_closed(instance): + us_service.close_userstory(instance) + else: + us_service.open_userstory(instance) + + +# Milestone +def _try_to_close_or_open_milestone_when_create_or_edit_us(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_us(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) diff --git a/taiga/projects/userstories/utils.py b/taiga/projects/userstories/utils.py new file mode 100644 index 000000000..11f9dcce2 --- /dev/null +++ b/taiga/projects/userstories/utils.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.projects.attachments.utils import attach_basic_attachments +from taiga.projects.attachments.utils import attach_total_attachments +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.history.utils import attach_total_comments_to_queryset +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset + + +def attach_total_points(queryset, as_field="total_points_attr"): + """Attach total of point values to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach the points as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT SUM(projects_points.value) + FROM userstories_rolepoints + INNER JOIN projects_points ON userstories_rolepoints.points_id = projects_points.id + WHERE userstories_rolepoints.user_story_id = {tbl}.id""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_role_points(queryset, as_field="role_points_attr"): + """Attach role point as json column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach the role points as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT FORMAT('{{%%s}}', + STRING_AGG(format( + '"%%s":%%s', + TO_JSON(userstories_rolepoints.role_id), + TO_JSON(userstories_rolepoints.points_id) + ), ',') + )::json + FROM userstories_rolepoints + WHERE userstories_rolepoints.user_story_id = {tbl}.id""" + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_tasks(queryset, as_field="tasks_attr"): + """Attach tasks as json column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach tasks as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + sql = """SELECT json_agg(row_to_json(t)) + FROM( + SELECT + tasks_task.id, + tasks_task.ref, + tasks_task.subject, + tasks_task.status_id, + tasks_task.is_blocked, + tasks_task.is_iocaine, + projects_taskstatus.is_closed + FROM tasks_task + INNER JOIN projects_taskstatus on projects_taskstatus.id = tasks_task.status_id + WHERE user_story_id = {tbl}.id + ORDER BY tasks_task.us_order, tasks_task.ref + ) t + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_epics(queryset, as_field="epics_attr"): + """Attach epics as json column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach the epics as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + sql = """SELECT json_agg(row_to_json(t)) + FROM (SELECT "epics_epic"."id" AS "id", + "epics_epic"."ref" AS "ref", + "epics_epic"."subject" AS "subject", + "epics_epic"."color" AS "color", + (SELECT row_to_json(p) + FROM (SELECT "projects_project"."id" AS "id", + "projects_project"."name" AS "name", + "projects_project"."slug" AS "slug" + ) p + ) AS "project" + FROM "epics_relateduserstory" + INNER JOIN "epics_epic" ON "epics_epic"."id" = "epics_relateduserstory"."epic_id" + INNER JOIN "projects_project" ON "projects_project"."id" = "epics_epic"."project_id" + WHERE "epics_relateduserstory"."user_story_id" = {tbl}.id + ORDER BY "projects_project"."name", "epics_epic"."ref") t""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_epic_order(queryset, epic_id, as_field="epic_order"): + """Attach epic_order column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param epic_id: Order related to this epic. + :param as_field: Attach order as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + sql = """SELECT "epics_relateduserstory"."order" AS "epic_order" + FROM "epics_relateduserstory" + WHERE "epics_relateduserstory"."user_story_id" = {tbl}.id and + "epics_relateduserstory"."epic_id" = {epic_id}""" + + sql = sql.format(tbl=model._meta.db_table, epic_id=epic_id) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False, include_tasks=False, epic_id=None): + queryset = attach_total_points(queryset) + queryset = attach_role_points(queryset) + queryset = attach_epics(queryset) + + if include_attachments: + queryset = attach_basic_attachments(queryset) + queryset = queryset.extra(select={"include_attachments": "True"}) + + if include_tasks: + queryset = attach_tasks(queryset) + queryset = queryset.extra(select={"include_tasks": "True"}) + + if epic_id is not None: + queryset = attach_epic_order(queryset, epic_id) + queryset = queryset.extra(select={"include_epic_order": "True"}) + + queryset = attach_total_attachments(queryset) + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + queryset = attach_total_comments_to_queryset(queryset) + return queryset + + +def attach_assigned_users(queryset, as_field="assigned_users_attr"): + """Attach assigned users as json column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach assigned as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + sql = """SELECT "userstories_userstory_assigned_users"."user_id" AS "user_id" + FROM "userstories_userstory_assigned_users" + WHERE "userstories_userstory_assigned_users"."userstory_id" = {tbl}.id""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset diff --git a/taiga/projects/userstories/validators.py b/taiga/projects/userstories/validators.py new file mode 100644 index 000000000..e2a8938aa --- /dev/null +++ b/taiga/projects/userstories/validators.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext 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.base.fields import ListField +from taiga.base.fields import PickledObjectField +from taiga.base.utils import json +from taiga.projects.milestones.models import Milestone +from taiga.projects.mixins.validators import AssignedToValidator +from taiga.projects.models import UserStoryStatus, Swimlane +from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer +from taiga.projects.notifications.validators import WatchersValidator +from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.userstories.models import UserStory +from taiga.projects.validators import ProjectExistsValidator + +from . import models + + +class UserStoryExistsValidator: + def validate_us_id(self, attrs, source): + value = attrs[source] + if not models.UserStory.objects.filter(pk=value).exists(): + msg = _("There's no user story with that id") + raise ValidationError(msg) + return attrs + + +class RolePointsField(serializers.WritableField): + def to_native(self, obj): + return {str(o.role.id): o.points.id for o in obj.all()} + + def from_native(self, obj): + if isinstance(obj, dict): + return obj + return json.loads(obj) + + +class UserStoryValidator(AssignedToValidator, WatchersValidator, + EditableWatchedResourceSerializer, validators.ModelValidator): + tags = TagsAndTagsColorsField(default=[], required=False) + external_reference = PgArrayField(required=False) + points = RolePointsField(source="role_points", required=False) + tribe_gig = PickledObjectField(required=False) + + class Meta: + model = models.UserStory + depth = 0 + read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner', 'kanban_order') + + +class UserStoriesBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + status_id = serializers.IntegerField(required=False) + swimlane_id = serializers.IntegerField(required=False) + bulk_stories = serializers.CharField() + + def validate_status_id(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + + if not UserStoryStatus.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story status id. The status must belong to " + "the same project.")) + + return attrs + + def validate_swimlane_id(self, attrs, source): + if attrs.get(source, None) is not None: + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + + if not Swimlane.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid swimlane id. The swimlane must belong to " + "the same project.")) + + return attrs + + +# Order bulk validators + +class UpdateUserStoriesBacklogOrderBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + milestone_id = serializers.IntegerField(required=False) + after_userstory_id = serializers.IntegerField(required=False) + before_userstory_id = serializers.IntegerField(required=False) + bulk_userstories = ListField(child=serializers.IntegerField(min_value=1)) + + def validate_milestone_id(self, attrs, source): + milestone_id = attrs.get(source, None) + + if milestone_id: + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + + if not Milestone.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid milestone id. The milestone must belong " + "to the same project.")) + + return attrs + + def validate_after_userstory_id(self, attrs, source): + if attrs.get(source, None) is not None: + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + milestone_id = attrs.get("milestone_id", None) + if milestone_id: + filters["milestone__id"] = milestone_id + else: + filters["milestone__isnull"] = True + + if not UserStory.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story id to move after. The user story must belong " + "to the same project and milestone.")) + + return attrs + + def validate_before_userstory_id(self, attrs, source): + before_userstory_id = attrs.get(source, None) + after_userstory_id = attrs.get("after_userstory_id", None) + + if after_userstory_id and before_userstory_id: + raise ValidationError(_("You can't use after and before at the same time.")) + elif before_userstory_id is not None: + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + milestone_id = attrs.get("milestone_id", None) + if milestone_id: + filters["milestone__id"] = milestone_id + else: + filters["milestone__isnull"] = True + + if not UserStory.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story id to move before. The user story must belong " + "to the same project and milestone.")) + + return attrs + + def validate_bulk_userstories(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id__in": attrs[source] + } + + if models.UserStory.objects.filter(**filters).count() != len(filters["id__in"]): + raise ValidationError(_("Invalid user story ids. All stories must belong to the same project.")) + + return attrs + + +class UpdateUserStoriesKanbanOrderBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + status_id = serializers.IntegerField() + swimlane_id = serializers.IntegerField(required=False) + after_userstory_id = serializers.IntegerField(required=False) + before_userstory_id = serializers.IntegerField(required=False) + bulk_userstories = ListField(child=serializers.IntegerField(min_value=1)) + + def validate_status_id(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + + if not UserStoryStatus.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story status id. The status must belong " + "to the same project.")) + + return attrs + + def validate_swimlane_id(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id": attrs[source] + } + + if not Swimlane.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid swimlane id. The swimlane must belong " + "to the same project.")) + + return attrs + + def validate_after_userstory_id(self, attrs, source): + if attrs.get(source, None) is not None: + filters = { + "project__id": attrs["project_id"], + "status__id": attrs["status_id"], + "id": attrs[source] + } + swimlane_id = attrs.get("swimlane_id", None) + if swimlane_id: + filters["swimlane__id"] = swimlane_id + else: + filters["swimlane__isnull"] = True + + if not UserStory.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story id to move after. The user story must belong " + "to the same project, status and swimlane.")) + + return attrs + + def validate_before_userstory_id(self, attrs, source): + before_userstory_id = attrs.get(source, None) + after_userstory_id = attrs.get("after_userstory_id", None) + + if after_userstory_id and before_userstory_id: + raise ValidationError(_("You can't use after and before at the same time.")) + elif before_userstory_id is not None: + filters = { + "project__id": attrs["project_id"], + "status__id": attrs["status_id"], + "id": attrs[source] + } + swimlane_id = attrs.get("swimlane_id", None) + if swimlane_id: + filters["swimlane__id"] = swimlane_id + else: + filters["swimlane__isnull"] = True + + if not UserStory.objects.filter(**filters).exists(): + raise ValidationError(_("Invalid user story id to move before. The user story must belong " + "to the same project, status and swimlane.")) + + return attrs + + def validate_bulk_userstories(self, attrs, source): + filters = { + "project__id": attrs["project_id"], + "id__in": attrs[source] + } + + if models.UserStory.objects.filter(**filters).count() != len(filters["id__in"]): + raise ValidationError(_("Invalid user story ids. All stories must belong to the same project.")) + + return attrs + + +# Milestone bulk validators + +class _UserStoryMilestoneBulkValidator(validators.Validator): + us_id = serializers.IntegerField() + order = serializers.IntegerField() + + +class UpdateMilestoneBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + milestone_id = serializers.IntegerField() + bulk_stories = _UserStoryMilestoneBulkValidator(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_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 diff --git a/taiga/projects/utils.py b/taiga/projects/utils.py new file mode 100644 index 000000000..4f854ad85 --- /dev/null +++ b/taiga/projects/utils.py @@ -0,0 +1,623 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +def attach_members(queryset, as_field="members_attr"): + """Attach a json members representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the members as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg(row_to_json(t)) + FROM ( + SELECT users_user.id, + users_user.username, + users_user.full_name, + users_user.email, + concat(full_name, username) complete_user_name, + users_user.color, + users_user.photo, + users_user.is_active, + users_role.id "role", + users_role.name role_name + FROM projects_membership + LEFT JOIN users_user ON projects_membership.user_id = users_user.id + LEFT JOIN users_role ON users_role.id = projects_membership.role_id + WHERE projects_membership.project_id = {tbl}.id + ORDER BY complete_user_name + ) t + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_milestones(queryset, as_field="milestones_attr"): + """Attach a json milestons representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the milestones as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg(row_to_json(t)) + FROM ( + SELECT milestones_milestone.id, + milestones_milestone.slug, + milestones_milestone.name, + milestones_milestone.closed + FROM milestones_milestone + WHERE milestones_milestone.project_id = {tbl}.id + ORDER BY estimated_start + ) t + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_closed_milestones(queryset, as_field="closed_milestones_attr"): + """Attach a closed milestones counter to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the counter as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT COUNT(milestones_milestone.id) + FROM milestones_milestone + WHERE milestones_milestone.project_id = {tbl}.id AND + milestones_milestone.closed = True + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_notify_policies(queryset, as_field="notify_policies_attr"): + """Attach a json notification policies representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the notification policies as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg(row_to_json(notifications_notifypolicy)) + FROM notifications_notifypolicy + WHERE notifications_notifypolicy.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_epic_statuses(queryset, as_field="epic_statuses_attr"): + """Attach a json epic statuses representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the epic statuses as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_epicstatus) + ORDER BY projects_epicstatus.order + ) + FROM projects_epicstatus + WHERE projects_epicstatus.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_swimlanes(queryset, as_field="swimlanes_attr"): + """Attach a json swimlanes representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the swimalne as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_swimlane) + ORDER BY projects_swimlane.order + ) + FROM projects_swimlane + WHERE projects_swimlane.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_userstory_statuses(queryset, as_field="userstory_statuses_attr"): + """Attach a json userstory statuses representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the userstory statuses as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_userstorystatus) + ORDER BY projects_userstorystatus.order + ) + FROM projects_userstorystatus + WHERE projects_userstorystatus.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_userstory_duedates(queryset, as_field="userstory_duedates_attr"): + """Attach a json userstory duedates representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the userstory duedates as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_userstoryduedate) + ORDER BY projects_userstoryduedate.order + ) + FROM projects_userstoryduedate + WHERE projects_userstoryduedate.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_points(queryset, as_field="points_attr"): + """Attach a json points representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the points as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_points) + ORDER BY projects_points.order + ) + FROM projects_points + WHERE projects_points.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_task_statuses(queryset, as_field="task_statuses_attr"): + """Attach a json task statuses representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the task statuses as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_taskstatus) + ORDER BY projects_taskstatus.order + ) + FROM projects_taskstatus + WHERE projects_taskstatus.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_task_duedates(queryset, as_field="task_duedates_attr"): + """Attach a json task duedates representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the task duedates as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_taskduedate) + ORDER BY projects_taskduedate.order + ) + FROM projects_taskduedate + WHERE projects_taskduedate.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_issue_statuses(queryset, as_field="issue_statuses_attr"): + """Attach a json issue statuses representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the statuses as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_issuestatus) + ORDER BY projects_issuestatus.order + ) + FROM projects_issuestatus + WHERE projects_issuestatus.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_issue_types(queryset, as_field="issue_types_attr"): + """Attach a json issue types representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the types as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_issuetype) + ORDER BY projects_issuetype.order + ) + FROM projects_issuetype + WHERE projects_issuetype.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_issue_duedates(queryset, as_field="issue_duedates_attr"): + """Attach a json issue duedates representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the duedates as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_issueduedate) + ORDER BY projects_issueduedate.order + ) + FROM projects_issueduedate + WHERE projects_issueduedate.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_priorities(queryset, as_field="priorities_attr"): + """Attach a json priorities representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the priorities as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_priority) + ORDER BY projects_priority.order + ) + FROM projects_priority + WHERE projects_priority.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_severities(queryset, as_field="severities_attr"): + """Attach a json severities representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the severities as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(projects_severity) + ORDER BY projects_severity.order + ) + FROM projects_severity + WHERE projects_severity.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_epic_custom_attributes(queryset, as_field="epic_custom_attributes_attr"): + """Attach a json epic custom attributes representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the epic custom attributes as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(custom_attributes_epiccustomattribute) + ORDER BY custom_attributes_epiccustomattribute.order + ) + FROM custom_attributes_epiccustomattribute + WHERE custom_attributes_epiccustomattribute.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_userstory_custom_attributes(queryset, as_field="userstory_custom_attributes_attr"): + """Attach a json userstory custom attributes representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the userstory custom attributes as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(custom_attributes_userstorycustomattribute) + ORDER BY custom_attributes_userstorycustomattribute.order + ) + FROM custom_attributes_userstorycustomattribute + WHERE custom_attributes_userstorycustomattribute.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_task_custom_attributes(queryset, as_field="task_custom_attributes_attr"): + """Attach a json task custom attributes representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the task custom attributes as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(custom_attributes_taskcustomattribute) + ORDER BY custom_attributes_taskcustomattribute.order + ) + FROM custom_attributes_taskcustomattribute + WHERE custom_attributes_taskcustomattribute.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_issue_custom_attributes(queryset, as_field="issue_custom_attributes_attr"): + """Attach a json issue custom attributes representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the issue custom attributes as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(custom_attributes_issuecustomattribute) + ORDER BY custom_attributes_issuecustomattribute.order + ) + FROM custom_attributes_issuecustomattribute + WHERE custom_attributes_issuecustomattribute.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_roles(queryset, as_field="roles_attr"): + """Attach a json roles representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the roles as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """ + SELECT json_agg( + row_to_json(users_role) + ORDER BY users_role.order + ) + FROM users_role + WHERE users_role.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_is_fan(queryset, user, as_field="is_fan_attr"): + """Attach a is fan boolean to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the boolean 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 false" + else: + sql = """ + SELECT COUNT(likes_like.id) > 0 + FROM likes_like + INNER JOIN django_content_type ON likes_like.content_type_id = django_content_type.id + WHERE django_content_type.model = 'project' AND + django_content_type.app_label = 'projects' AND + likes_like.user_id = {user_id} AND + likes_like.object_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table, user_id=user.id) + + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_my_role_permissions(queryset, user, as_field="my_role_permissions_attr"): + """Attach a permission array to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the permissions 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 users_role.permissions + FROM projects_membership + LEFT JOIN users_user ON projects_membership.user_id = users_user.id + LEFT JOIN users_role ON users_role.id = projects_membership.role_id + WHERE projects_membership.project_id = {tbl}.id AND + users_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_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) + queryset = attach_notify_policies(queryset) + queryset = attach_epic_statuses(queryset) + queryset = attach_swimlanes(queryset) + queryset = attach_userstory_statuses(queryset) + queryset = attach_userstory_duedates(queryset) + queryset = attach_points(queryset) + queryset = attach_task_statuses(queryset) + queryset = attach_task_duedates(queryset) + queryset = attach_issue_statuses(queryset) + queryset = attach_issue_duedates(queryset) + queryset = attach_issue_types(queryset) + queryset = attach_priorities(queryset) + queryset = attach_severities(queryset) + queryset = attach_epic_custom_attributes(queryset) + queryset = attach_userstory_custom_attributes(queryset) + queryset = attach_task_custom_attributes(queryset) + queryset = attach_issue_custom_attributes(queryset) + queryset = attach_roles(queryset) + queryset = attach_is_fan(queryset, user) + queryset = attach_my_role_permissions(queryset, user) + queryset = attach_milestones(queryset) + queryset = attach_my_homepage(queryset, user) + + return queryset + + +def attach_basic_info(queryset, user=None): + """Attach basic information to each object of the queryset. It's a conservative approach, + could be reduced in future versions. + + :param queryset: A Django projects queryset object. + + :return: Queryset + """ + queryset = attach_members(queryset) + queryset = attach_notify_policies(queryset) + queryset = attach_is_fan(queryset, user) + queryset = attach_my_role_permissions(queryset, user) + queryset = attach_my_homepage(queryset, user) + + return queryset diff --git a/taiga/projects/validators.py b/taiga/projects/validators.py new file mode 100644 index 000000000..86d378df3 --- /dev/null +++ b/taiga/projects/validators.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import Q +from django.utils.translation import gettext as _ + +from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.api.fields import validate_user_email_allowed_domains, InvalidEmailValidationError +from taiga.base.exceptions import ValidationError +from taiga.base.fields import JSONField +from taiga.base.fields import PgArrayField +from taiga.users.models import User, Role + + +from .tagging.fields import TagsField + +from . import models +from . import services + + +class DuplicatedNameInProjectValidator: + def validate_name(self, attrs, source): + """ + Check the points name is not duplicated in the project on creation + """ + model = self.opts.model + qs = None + # If the object exists: + if self.object and attrs.get(source, None): + qs = model.objects.filter( + project=self.object.project, + name=attrs[source]).exclude(id=self.object.id) + + if not self.object and attrs.get("project", None) and attrs.get(source, None): + qs = model.objects.filter(project=attrs["project"], name=attrs[source]) + + if qs and qs.exists(): + raise ValidationError(_("Duplicated name")) + + return attrs + + +class ProjectExistsValidator: + def validate_project_id(self, attrs, source): + value = attrs[source] + if not models.Project.objects.filter(pk=value).exists(): + msg = _("There's no project with that id") + raise ValidationError(msg) + return attrs + + +###################################################### +# Custom values for selectors +###################################################### + +class EpicStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.EpicStatus + + +class UserStoryStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.UserStoryStatus + + +class PointsValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.Points + + +class SwimlaneValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.Swimlane + read_only_fields = ("order",) + + +class SwimlaneUserStoryStatusValidator(validators.ModelValidator): + class Meta: + model = models.SwimlaneUserStoryStatus + read_only_fields = ("swimlane", "status") + + +class UserStoryDueDateValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.UserStoryDueDate + + +class TaskStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.TaskStatus + + +class TaskDueDateValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.TaskDueDate + + +class SeverityValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.Severity + + +class PriorityValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.Priority + + +class IssueStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.IssueStatus + + +class IssueTypeValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.IssueType + + +class IssueDueDateValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.IssueDueDate + + +class DueDatesCreationValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + + +###################################################### +# Members +###################################################### + +class MembershipValidator(validators.ModelValidator): + username = serializers.CharField(required=True) + + class Meta: + model = models.Membership + read_only_fields = ("user", "email") + + def restore_object(self, attrs, instance=None): + username = attrs.pop("username", None) + obj = super(MembershipValidator, self).restore_object(attrs, instance=instance) + obj.username = username + return obj + + def _validate_member_doesnt_exist(self, attrs, email): + project = attrs.get("project", None if self.object is None else self.object.project) + if project is None: + return attrs + + qs = models.Membership.objects.all() + + # If self.object is not None, the serializer is in update + # mode, and for it, it should exclude self. + if self.object: + qs = qs.exclude(pk=self.object.pk) + + qs = qs.filter(Q(project_id=project.id, user__email=email) | + Q(project_id=project.id, email=email)) + + if qs.count() > 0: + raise ValidationError(_("The user yet exists in the project")) + + def validate_project(self, attrs, source): + # Create only + if self.object is not None and self.object.project != attrs.get("project"): + raise ValidationError(_("Invalid operation")) + + return attrs + + def validate_role(self, attrs, source): + project = attrs.get("project", None if self.object is None else self.object.project) + if project is None: + return attrs + + role = attrs[source] + + if project.roles.filter(id=role.id).count() == 0: + raise ValidationError(_("Invalid role for the project")) + + return attrs + + def validate_username(self, attrs, source): + username = attrs.get(source, None) + try: + validate_user_email_allowed_domains(username) + + except ValidationError: + # If the validation comes from a request let's check the user is a valid contact + request = self.context.get("request", None) + if request is not None and request.user.is_authenticated: + valid_usernames = request.user.contacts_visible_by_user(request.user).values_list("username", flat=True) + if username not in valid_usernames: + raise ValidationError(_("The user must be a valid contact")) + + user = User.objects.filter(Q(username=username) | Q(email=username)).first() + if user is not None: + email = user.email + self.user = user + + else: + email = username + + self.email = email + self._validate_member_doesnt_exist(attrs, email) + return attrs + + def validate_is_admin(self, attrs, source): + project = attrs.get("project", None if self.object is None else self.object.project) + if project is None: + return attrs + + if (self.object and self.object.user): + if self.object.user.id == project.owner_id and not attrs[source]: + raise ValidationError(_("The project owner must be admin.")) + + if not services.project_has_valid_admins(project, exclude_user=self.object.user): + raise ValidationError( + _("At least one user must be an active admin for this project.") + ) + + return attrs + + def validate(self, attrs): + request = self.context.get("request", None) + if request is not None and request.user.is_authenticated and not request.user.verified_email: + raise ValidationError(_("To add members to a project, first you have to verify your email address")) + + return super().validate(attrs) + + def is_valid(self): + errors = super().is_valid() + if hasattr(self, "email") and self.object is not None: + self.object.email = self.email + + if hasattr(self, "user") and self.object is not None: + self.object.user = self.user + + return errors + + +class _MemberBulkValidator(validators.Validator): + username = serializers.CharField() + role_id = serializers.IntegerField() + + def validate_username(self, attrs, source): + username = attrs.get(source) + try: + validate_user_email_allowed_domains(username) + except InvalidEmailValidationError: + # If the validation comes from a request let's check the user is a valid contact + request = self.context.get("request", None) + if request is not None and request.user.is_authenticated: + all_usernames = request.user.contacts_visible_by_user(request.user).values_list("username", flat=True) + valid_usernames = set(all_usernames) + if username not in valid_usernames: + raise ValidationError(_("The user must be a valid contact")) + + return attrs + + +class MembersBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + bulk_memberships = _MemberBulkValidator(many=True) + invitation_extra_text = serializers.CharField(required=False, max_length=255) + + def validate_bulk_memberships(self, attrs, source): + project_id = attrs["project_id"] + role_ids = [r["role_id"] for r in attrs["bulk_memberships"]] + + if Role.objects.filter(project_id=project_id, id__in=role_ids).count() != len(set(role_ids)): + raise ValidationError(_("Invalid role ids. All roles must belong to the same project.")) + + return attrs + + +###################################################### +# Projects +###################################################### + +class ProjectValidator(validators.ModelValidator): + anon_permissions = PgArrayField(required=False) + public_permissions = PgArrayField(required=False) + tags = TagsField(default=[], required=False) + + class Meta: + model = models.Project + read_only_fields = ("created_date", "modified_date", "slug", "blocked_code", "owner") + + +###################################################### +# Project Templates +###################################################### + +class ProjectTemplateValidator(validators.ModelValidator): + default_options = JSONField(required=False, label=_("Default options")) + us_statuses = JSONField(required=False, label=_("User story's statuses")) + points = JSONField(required=False, label=_("Points")) + task_statuses = JSONField(required=False, label=_("Task's statuses")) + issue_statuses = JSONField(required=False, label=_("Issue's statuses")) + issue_types = JSONField(required=False, label=_("Issue's types")) + priorities = JSONField(required=False, label=_("Priorities")) + severities = JSONField(required=False, label=_("Severities")) + roles = JSONField(required=False, label=_("Roles")) + + class Meta: + model = models.ProjectTemplate + read_only_fields = ("created_date", "modified_date") + + +###################################################### +# Project order bulk validators +###################################################### + +class UpdateProjectOrderBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + order = serializers.IntegerField() + + +###################################################### +# Project duplication validator +###################################################### + + +class DuplicateProjectMemberValidator(validators.Validator): + id = serializers.IntegerField() + + +class DuplicateProjectValidator(validators.Validator): + name = serializers.CharField() + description = serializers.CharField() + is_private = serializers.BooleanField() + users = DuplicateProjectMemberValidator(many=True) diff --git a/taiga/projects/votes/__init__.py b/taiga/projects/votes/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/votes/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/votes/admin.py b/taiga/projects/votes/admin.py new file mode 100644 index 000000000..cf3614e5e --- /dev/null +++ b/taiga/projects/votes/admin.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin +from django.contrib.contenttypes.admin import GenericTabularInline + +from . import models + + +class VoteInline(GenericTabularInline): + model = models.Vote + extra = 0 + raw_id_fields = ["user"] diff --git a/taiga/projects/votes/migrations/0001_initial.py b/taiga/projects/votes/migrations/0001_initial.py new file mode 100644 index 000000000..40863418e --- /dev/null +++ b/taiga/projects/votes/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Vote', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), + ('object_id', models.PositiveIntegerField()), + ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), + ('user', models.ForeignKey(verbose_name='votes', to=settings.AUTH_USER_MODEL, related_name='votes', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name': 'Vote', + 'verbose_name_plural': 'Votes', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Votes', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), + ('object_id', models.PositiveIntegerField()), + ('count', models.PositiveIntegerField(default=0)), + ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name': 'Votes', + 'verbose_name_plural': 'Votes', + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='votes', + unique_together=set([('content_type', 'object_id')]), + ), + migrations.AlterUniqueTogether( + name='vote', + unique_together=set([('content_type', 'object_id', 'user')]), + ), + ] diff --git a/taiga/projects/votes/migrations/0002_auto_20150805_1600.py b/taiga/projects/votes/migrations/0002_auto_20150805_1600.py new file mode 100644 index 000000000..4dd1576c6 --- /dev/null +++ b/taiga/projects/votes/migrations/0002_auto_20150805_1600.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.utils.timezone import utc +from django.conf import settings +import datetime + + +class Migration(migrations.Migration): + + dependencies = [ + ('votes', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vote', + name='created_date', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2015, 8, 5, 16, 0, 40, 158374, tzinfo=utc), verbose_name='created date'), + preserve_default=False, + ), + migrations.AlterField( + model_name='vote', + name='user', + field=models.ForeignKey(related_name='votes', to=settings.AUTH_USER_MODEL, verbose_name='user', on_delete=models.CASCADE), + preserve_default=True, + ), + migrations.AlterField( + model_name='votes', + name='count', + field=models.PositiveIntegerField(default=0, verbose_name='count'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/votes/migrations/__init__.py b/taiga/projects/votes/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/votes/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/votes/mixins/__init__.py b/taiga/projects/votes/mixins/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/votes/mixins/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/votes/mixins/serializers.py b/taiga/projects/votes/mixins/serializers.py new file mode 100644 index 000000000..fab039bbd --- /dev/null +++ b/taiga/projects/votes/mixins/serializers.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import MethodField + + +class VoteResourceSerializerMixin(serializers.LightSerializer): + is_voter = MethodField() + total_voters = MethodField() + + def get_is_voter(self, obj): + # The "is_voted" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "is_voter", False) or False + + def get_total_voters(self, obj): + # The "total_voters" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "total_voters", 0) or 0 diff --git a/taiga/projects/votes/mixins/viewsets.py b/taiga/projects/votes/mixins/viewsets.py new file mode 100644 index 000000000..6ec5750f0 --- /dev/null +++ b/taiga/projects/votes/mixins/viewsets.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.exceptions import ObjectDoesNotExist + +from taiga.base import response +from taiga.base.api import viewsets +from taiga.base.api.utils import get_object_or_error +from taiga.base.decorators import detail_route + +from taiga.projects.votes import serializers +from taiga.projects.votes import services + + +class VotedResourceMixin: + """ + Note: Update get_queryset method: + def get_queryset(self): + qs = super().get_queryset() + return self.attach_votes_attrs_to_queryset(qs) + + - the classes using this mixing must have a method: + def pre_conditions_on_save(self, obj) + """ + + @detail_route(methods=["POST"]) + def upvote(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "upvote", obj) + self.pre_conditions_on_save(obj) + + services.add_vote(obj, user=request.user) + return response.Ok() + + @detail_route(methods=["POST"]) + def downvote(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "downvote", obj) + self.pre_conditions_on_save(obj) + + services.remove_vote(obj, user=request.user) + return response.Ok() + + +class VotersViewSetMixin: + # Is a ModelListViewSet with two required params: permission_classes and resource_model + serializer_class = serializers.VoterSerializer + list_serializer_class = serializers.VoterSerializer + permission_classes = None + resource_model = None + + def retrieve(self, request, *args, **kwargs): + pk = kwargs.get("pk", None) + resource_id = kwargs.get("resource_id", None) + resource = get_object_or_error(self.resource_model, request.user, pk=resource_id) + + self.check_permissions(request, 'retrieve', resource) + + try: + self.object = services.get_voters(resource).get(pk=pk) + except ObjectDoesNotExist: # or User.DoesNotExist + return response.NotFound() + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + def list(self, request, *args, **kwargs): + resource_id = kwargs.get("resource_id", None) + resource = get_object_or_error(self.resource_model, request.user, pk=resource_id) + + self.check_permissions(request, 'list', resource) + + return super().list(request, *args, **kwargs) + + def get_queryset(self): + resource = self.resource_model.objects.get(pk=self.kwargs.get("resource_id")) + return services.get_voters(resource) diff --git a/taiga/projects/votes/models.py b/taiga/projects/votes/models.py new file mode 100644 index 000000000..838738e8e --- /dev/null +++ b/taiga/projects/votes/models.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Votes(models.Model): + content_type = models.ForeignKey("contenttypes.ContentType", on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + count = models.PositiveIntegerField(null=False, blank=False, default=0, verbose_name=_("count")) + + class Meta: + verbose_name = _("Votes") + verbose_name_plural = _("Votes") + unique_together = ("content_type", "object_id") + + @property + def project(self): + if hasattr(self.content_object, 'project'): + return self.content_object.project + return None + + def __str__(self): + return self.count + + +class Vote(models.Model): + content_type = models.ForeignKey("contenttypes.ContentType", on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=False, + blank=False, + related_name="votes", + verbose_name=_("user"), + on_delete=models.CASCADE, + ) + created_date = models.DateTimeField(null=False, blank=False, auto_now_add=True, + verbose_name=_("created date")) + + class Meta: + verbose_name = _("Vote") + verbose_name_plural = _("Votes") + unique_together = ("content_type", "object_id", "user") + + @property + def project(self): + if hasattr(self.content_object, 'project'): + return self.content_object.project + return None + + def __str__(self): + return self.user.get_full_name() diff --git a/taiga/projects/votes/serializers.py b/taiga/projects/votes/serializers.py new file mode 100644 index 000000000..436f86478 --- /dev/null +++ b/taiga/projects/votes/serializers.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField + + +class VoterSerializer(serializers.LightSerializer): + id = Field() + username = Field() + full_name = MethodField() + + def get_full_name(self, obj): + return obj.get_full_name() diff --git a/taiga/projects/votes/services.py b/taiga/projects/votes/services.py new file mode 100644 index 000000000..2391c805f --- /dev/null +++ b/taiga/projects/votes/services.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import F +from django.db import transaction as tx + +from django.apps import apps +from django.contrib.auth import get_user_model + +from django_pglocks import advisory_lock + +from .models import Votes, Vote + + +@tx.atomic +def add_vote(obj, user): + """Add a vote to an object. + + If the user has already voted the object nothing happends, so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User adding the vote. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with advisory_lock("vote-{}-{}".format(obj_type.id, obj.id)): + vote, created = Vote.objects.get_or_create(content_type=obj_type, object_id=obj.id, user=user) + if not created: + return + + votes, _ = Votes.objects.get_or_create(content_type=obj_type, object_id=obj.id) + votes.count = F('count') + 1 + votes.save() + return vote + + +@tx.atomic +def remove_vote(obj, user): + """Remove an user vote from an object. + + If the user has not voted the object nothing happens so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User removing her vote. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with advisory_lock("vote-{}-{}".format(obj_type.id, obj.id)): + qs = Vote.objects.filter(content_type=obj_type, object_id=obj.id, user=user) + if not qs.exists(): + return + + qs.delete() + + votes, _ = Votes.objects.get_or_create(content_type=obj_type, object_id=obj.id) + votes.count = F('count') - 1 + votes.save() + + +def get_voters(obj): + """Get the voters of an object. + + :param obj: Any Django model instance. + + :return: User queryset object representing the users that voted the object. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + return get_user_model().objects.filter(votes__content_type=obj_type, votes__object_id=obj.id) + + +def get_votes(obj): + """Get the number of votes an object has. + + :param obj: Any Django model instance. + + :return: Number of votes or `0` if the object has no votes at all. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + + try: + return Votes.objects.get(content_type=obj_type, object_id=obj.id).count + except Votes.DoesNotExist: + return 0 + + +def get_voted(user_or_id, model): + """Get the objects voted by an user. + + :param user_or_id: :class:`~taiga.users.models.User` instance or id. + :param model: Show only objects of this kind. Can be any Django model class. + + :return: Queryset of objects representing the votes of the user. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + conditions = ('votes_vote.content_type_id = %s', + '%s.id = votes_vote.object_id' % model._meta.db_table, + 'votes_vote.user_id = %s') + + if isinstance(user_or_id, get_user_model()): + user_id = user_or_id.id + else: + user_id = user_or_id + + return model.objects.extra(where=conditions, tables=('votes_vote',), + params=(obj_type.id, user_id)) diff --git a/taiga/projects/votes/utils.py b/taiga/projects/votes/utils.py new file mode 100644 index 000000000..c95bc7aa6 --- /dev/null +++ b/taiga/projects/votes/utils.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps + + +def attach_total_voters_to_queryset(queryset, as_field="total_voters"): + """Attach votes count to each object of the queryset. + + Because of laziness of vote objects creation, this makes much simpler and more efficient to + access to voted-object number of votes. + + (The other way was to do it in the serializer with some try/except blocks and additional + queries) + + :param queryset: A Django queryset object. + :param as_field: Attach the votes-count as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + sql = """SELECT coalesce(SUM(total_voters), 0) FROM ( + SELECT coalesce(votes_votes.count, 0) total_voters + FROM votes_votes + WHERE votes_votes.content_type_id = {type_id} + AND votes_votes.object_id = {tbl}.id + ) as e""" + + sql = sql.format(type_id=type.id, tbl=model._meta.db_table) + qs = queryset.extra(select={as_field: sql}) + return qs + + +def attach_is_voter_to_queryset(queryset, user, as_field="is_voter"): + """Attach is_vote boolean to each object of the queryset. + + Because of laziness of vote objects creation, this makes much simpler and more efficient to + access to votes-object and check if the curren user vote it. + + (The other way was to do it in the serializer with some try/except blocks and additional + queries) + + :param queryset: A Django queryset object. + :param user: A users.User object model + :param as_field: Attach the boolean as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + if user is None or user.is_anonymous: + sql = """SELECT false""" + else: + sql = ("""SELECT CASE WHEN (SELECT count(*) + FROM votes_vote + WHERE votes_vote.content_type_id = {type_id} + AND votes_vote.object_id = {tbl}.id + AND votes_vote.user_id = {user_id}) > 0 + THEN TRUE + ELSE FALSE + END""") + sql = sql.format(type_id=type.id, tbl=model._meta.db_table, user_id=user.id) + + qs = queryset.extra(select={as_field: sql}) + return qs diff --git a/taiga/projects/wiki/__init__.py b/taiga/projects/wiki/__init__.py new file mode 100644 index 000000000..513092d70 --- /dev/null +++ b/taiga/projects/wiki/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# diff --git a/taiga/projects/wiki/admin.py b/taiga/projects/wiki/admin.py new file mode 100644 index 000000000..8f5ee2a2f --- /dev/null +++ b/taiga/projects/wiki/admin.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib import admin + +from taiga.projects.attachments.admin import AttachmentInline +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + +from taiga.projects.wiki.models import WikiPage + +from . import models + +class WikiPageAdmin(admin.ModelAdmin): + list_display = ["project", "slug", "owner"] + list_display_links = list_display + inlines = [WatchedInline, VoteInline] + raw_id_fields = ["project"] + + def get_object(self, *args, **kwargs): + self.obj = super().get_object(*args, **kwargs) + return self.obj + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if (db_field.name in ["owner", "last_modifier"] and getattr(self, 'obj', None)): + kwargs["queryset"] = db_field.related_model.objects.filter( + memberships__project=self.obj.project) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + +admin.site.register(models.WikiPage, WikiPageAdmin) + +class WikiLinkAdmin(admin.ModelAdmin): + list_display = ["project", "title"] + list_display_links = list_display + raw_id_fields = ["project"] + +admin.site.register(models.WikiLink, WikiLinkAdmin) diff --git a/taiga/projects/wiki/api.py b/taiga/projects/wiki/api.py new file mode 100644 index 000000000..f2b18b497 --- /dev/null +++ b/taiga/projects/wiki/api.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base import exceptions as exc +from taiga.base import filters +from taiga.base import response +from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelListViewSet +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.api.utils import get_object_or_404 +from taiga.base.decorators import list_route + +from taiga.mdrender.service import render as mdrender + +from taiga.projects.history.mixins import HistoryResourceMixin +from taiga.projects.history.services import take_snapshot +from taiga.projects.models import Project +from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.notifications.mixins import WatchersViewSetMixin +from taiga.projects.notifications.services import analize_object_for_watchers +from taiga.projects.notifications.services import send_notifications +from taiga.projects.occ import OCCResourceMixin + +from . import models +from . import permissions +from . import serializers +from . import validators +from . import utils as wiki_utils + + +class WikiViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, + BlockedByProjectMixin, ModelCrudViewSet): + + model = models.WikiPage + serializer_class = serializers.WikiPageSerializer + validator_class = validators.WikiPageValidator + permission_classes = (permissions.WikiPagePermission,) + filter_backends = (filters.CanViewWikiPagesFilterBackend,) + filter_fields = ("project", "slug") + queryset = models.WikiPage.objects.all() + + def get_queryset(self): + qs = super().get_queryset() + qs = wiki_utils.attach_extra_info(qs, user=self.request.user) + return qs + + @list_route(methods=["GET"]) + def by_slug(self, request): + slug = request.QUERY_PARAMS.get("slug", None) + project_id = request.QUERY_PARAMS.get("project", None) + wiki_page = get_object_or_404(models.WikiPage, slug=slug, project_id=project_id) + return self.retrieve(request, pk=wiki_page.pk) + + @list_route(methods=["POST"]) + def render(self, request, **kwargs): + content = request.DATA.get("content", None) + project_id = request.DATA.get("project_id", None) + + if not content: + raise exc.WrongArguments({"content": _("'content' parameter is mandatory")}) + + if not project_id: + raise exc.WrongArguments({"project_id": _("'project_id' parameter is mandatory")}) + + project = get_object_or_404(Project, pk=project_id) + + self.check_permissions(request, "render", project) + + data = mdrender(project, content) + return response.Ok({"data": data}) + + def pre_save(self, obj): + if not obj.owner: + obj.owner = self.request.user + obj.last_modifier = self.request.user + + super().pre_save(obj) + + +class WikiWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.WikiPageWatchersPermission,) + resource_model = models.WikiPage + + +class WikiLinkViewSet(BlockedByProjectMixin, ModelCrudViewSet): + model = models.WikiLink + serializer_class = serializers.WikiLinkSerializer + validator_class = validators.WikiLinkValidator + permission_classes = (permissions.WikiLinkPermission,) + filter_backends = (filters.CanViewWikiPagesFilterBackend,) + filter_fields = ["project"] + + def post_save(self, obj, created=False): + if created: + self._create_wiki_page_when_create_wiki_link_if_not_exist(self.request, obj) + super().pre_save(obj) + + def _create_wiki_page_when_create_wiki_link_if_not_exist(self, request, wiki_link): + try: + self.check_permissions(request, "create_wiki_page", wiki_link) + except exc.PermissionDenied: + # Create only the wiki link because the user doesn't have permission. + pass + else: + # Create the wiki link and the wiki page if not exist. + wiki_page, created = models.WikiPage.objects.get_or_create( + slug=wiki_link.href, + project=wiki_link.project, + defaults={"owner": self.request.user, "last_modifier": self.request.user}) + + if created: + # Create the new history entry, Set watcher for the new wiki page + # and send notifications about the new page created + history = take_snapshot(wiki_page, user=self.request.user) + analize_object_for_watchers(wiki_page, history.comment, history.owner) + send_notifications(wiki_page, history=history) diff --git a/taiga/projects/wiki/apps.py b/taiga/projects/wiki/apps.py new file mode 100644 index 000000000..8642a9ce6 --- /dev/null +++ b/taiga/projects/wiki/apps.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# +from django.apps import AppConfig + + +class WikiAppConfig(AppConfig): + name = "taiga.projects.wiki" + verbose_name = "Wiki" + watched_types = ["wiki.wiki_page", ] diff --git a/taiga/projects/wiki/migrations/0001_initial.py b/taiga/projects/wiki/migrations/0001_initial.py new file mode 100644 index 000000000..2baa5fffe --- /dev/null +++ b/taiga/projects/wiki/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0002_auto_20140903_0920'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WikiLink', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, verbose_name='ID', primary_key=True)), + ('title', models.CharField(max_length=500)), + ('href', models.SlugField(max_length=500, verbose_name='href')), + ('order', models.PositiveSmallIntegerField(default=1, verbose_name='order')), + ('project', models.ForeignKey(verbose_name='project', related_name='wiki_links', to='projects.Project', on_delete=models.CASCADE)), + ], + options={ + 'ordering': ['project', 'order'], + 'verbose_name_plural': 'wiki links', + 'verbose_name': 'wiki link', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='WikiPage', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, verbose_name='ID', primary_key=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('slug', models.SlugField(max_length=500, verbose_name='slug')), + ('content', models.TextField(blank=True, verbose_name='content')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('last_modifier', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, verbose_name='last modifier', related_name='last_modified_wiki_pages', blank=True, on_delete=models.SET_NULL)), + ('owner', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, verbose_name='owner', related_name='owned_wiki_pages', blank=True, on_delete=models.SET_NULL)), + ('project', models.ForeignKey(verbose_name='project', related_name='wiki_pages', to='projects.Project', on_delete=models.CASCADE)), + ('watchers', models.ManyToManyField(null=True, related_name='wiki_wikipage+', blank=True, verbose_name='watchers', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['project', 'slug'], + 'verbose_name_plural': 'wiki pages', + 'verbose_name': 'wiki page', + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='wikipage', + unique_together=set([('project', 'slug')]), + ), + migrations.AlterUniqueTogether( + name='wikilink', + unique_together=set([('project', 'href')]), + ), + ] diff --git a/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py b/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py new file mode 100644 index 000000000..18b90e7d1 --- /dev/null +++ b/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import connection +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from taiga.base.utils.contenttypes import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes(verbosity=0) + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT wikipage_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM wiki_wikipage_watchers INNER JOIN wiki_wikipage ON wiki_wikipage_watchers.wikipage_id = wiki_wikipage.id""".format(content_type_id=ContentType.objects.get(model='wikipage').id) + cursor = connection.cursor() + cursor.execute(sql) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('wiki', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='wikipage', + name='watchers', + ), + ] diff --git a/taiga/projects/wiki/migrations/0003_auto_20160615_0721.py b/taiga/projects/wiki/migrations/0003_auto_20160615_0721.py new file mode 100644 index 000000000..e970fce49 --- /dev/null +++ b/taiga/projects/wiki/migrations/0003_auto_20160615_0721.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-15 07:21 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wiki', '0002_remove_wikipage_watchers'), + ] + + operations = [ + migrations.AlterModelOptions( + name='wikilink', + options={'ordering': ['project', 'order', 'id'], 'verbose_name': 'wiki link', 'verbose_name_plural': 'wiki links'}, + ), + migrations.AlterField( + model_name='wikilink', + name='order', + field=models.PositiveSmallIntegerField(default='10000', verbose_name='order'), + ), + ] diff --git a/taiga/projects/wiki/migrations/0004_auto_20160928_0540.py b/taiga/projects/wiki/migrations/0004_auto_20160928_0540.py new file mode 100644 index 000000000..50d60e01d --- /dev/null +++ b/taiga/projects/wiki/migrations/0004_auto_20160928_0540.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-09-28 05:40 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.base.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ('wiki', '0003_auto_20160615_0721'), + ] + + operations = [ + migrations.AlterField( + model_name='wikilink', + name='order', + field=models.BigIntegerField(default=taiga.base.utils.time.timestamp_ms, verbose_name='order'), + ), + ] diff --git a/taiga/projects/wiki/migrations/0005_auto_20161201_1628.py b/taiga/projects/wiki/migrations/0005_auto_20161201_1628.py new file mode 100644 index 000000000..8b495a98b --- /dev/null +++ b/taiga/projects/wiki/migrations/0005_auto_20161201_1628.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.3 on 2016-12-01 16:28 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wiki', '0004_auto_20160928_0540'), + ] + + operations = [ + migrations.AlterField( + model_name='wikipage', + name='slug', + field=models.SlugField(allow_unicode=True, max_length=500, verbose_name='slug'), + ), + ] diff --git a/taiga/projects/wiki/migrations/__init__.py b/taiga/projects/wiki/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/projects/wiki/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/projects/wiki/models.py b/taiga/projects/wiki/models.py new file mode 100644 index 000000000..e3e7fb9d8 --- /dev/null +++ b/taiga/projects/wiki/models.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.contrib.contenttypes.fields import GenericRelation +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from django_pglocks import advisory_lock + +from taiga.base.utils.slug import slugify_uniquely_for_queryset +from taiga.base.utils.time import timestamp_ms +from taiga.projects.notifications.mixins import WatchedModelMixin +from taiga.projects.occ import OCCModelMixin + + +class WikiPage(OCCModelMixin, WatchedModelMixin, models.Model): + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="wiki_pages", + verbose_name=_("project"), + on_delete=models.CASCADE + ) + slug = models.SlugField(max_length=500, db_index=True, null=False, blank=False, + verbose_name=_("slug"), allow_unicode=True) + content = models.TextField(null=False, blank=True, + verbose_name=_("content")) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + related_name="owned_wiki_pages", + verbose_name=_("owner"), + on_delete=models.SET_NULL, + ) + last_modifier = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + related_name="last_modified_wiki_pages", + verbose_name=_("last modifier"), + on_delete=models.SET_NULL, + ) + created_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("created date"), + default=timezone.now) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + attachments = GenericRelation("attachments.Attachment") + _importing = None + + class Meta: + verbose_name = "wiki page" + verbose_name_plural = "wiki pages" + ordering = ["project", "slug"] + unique_together = ("project", "slug",) + + def __str__(self): + return "project {0} - {1}".format(self.project_id, self.slug) + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + return super().save(*args, **kwargs) + + +class WikiLink(models.Model): + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="wiki_links", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + title = models.CharField(max_length=500, null=False, blank=False) + href = models.SlugField(max_length=500, db_index=True, null=False, blank=False, + verbose_name=_("href")) + order = models.BigIntegerField(null=False, blank=False, default=timestamp_ms, + verbose_name=_("order")) + + class Meta: + verbose_name = "wiki link" + verbose_name_plural = "wiki links" + ordering = ["project", "order", "id"] + unique_together = ("project", "href") + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.href: + with advisory_lock("wiki-page-creation-{}".format(self.project_id)): + wl_qs = self.project.wiki_links.all() + self.href = slugify_uniquely_for_queryset(self.title, wl_qs, slugfield="href") + super().save(*args, **kwargs) + else: + super().save(*args, **kwargs) diff --git a/taiga/projects/wiki/permissions.py b/taiga/projects/wiki/permissions.py new file mode 100644 index 000000000..e5451d6a7 --- /dev/null +++ b/taiga/projects/wiki/permissions.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, + IsAuthenticated, IsProjectAdmin, AllowAny, + IsSuperUser) + +from taiga.permissions.permissions import CommentAndOrUpdatePerm + + +class WikiPagePermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_wiki_pages') + by_slug_perms = HasProjectPerm('view_wiki_pages') + create_perms = HasProjectPerm('add_wiki_page') + update_perms = CommentAndOrUpdatePerm('modify_wiki_page', 'comment_wiki_page') + partial_update_perms = CommentAndOrUpdatePerm('modify_wiki_page', 'comment_wiki_page') + destroy_perms = HasProjectPerm('delete_wiki_page') + list_perms = AllowAny() + render_perms = AllowAny() + watch_perms = IsAuthenticated() & HasProjectPerm('view_wiki_pages') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_wiki_pages') + + +class WikiPageWatchersPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_wiki_pages') + list_perms = HasProjectPerm('view_wiki_pages') + + +class WikiLinkPermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_wiki_links') + create_perms = HasProjectPerm('add_wiki_link') + update_perms = HasProjectPerm('modify_wiki_link') + partial_update_perms = HasProjectPerm('modify_wiki_link') + destroy_perms = HasProjectPerm('delete_wiki_link') + list_perms = AllowAny() + create_wiki_page_perms = HasProjectPerm('add_wiki_page') diff --git a/taiga/projects/wiki/serializers.py b/taiga/projects/wiki/serializers.py new file mode 100644 index 000000000..2ab15fd64 --- /dev/null +++ b/taiga/projects/wiki/serializers.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.projects.history import services as history_service +from taiga.projects.mixins.serializers import ProjectExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer +from taiga.mdrender.service import render as mdrender + + +class WikiPageSerializer( + WatchedResourceSerializer, ProjectExtraInfoSerializerMixin, + serializers.LightSerializer +): + id = Field() + project = Field(attr="project_id") + slug = Field() + content = Field() + owner = Field(attr="owner_id") + last_modifier = Field(attr="last_modifier_id") + created_date = Field() + modified_date = Field() + + html = MethodField() + editions = MethodField() + + version = Field() + + def get_html(self, obj): + return mdrender(obj.project, obj.content) + + def get_editions(self, obj): + return history_service.get_history_queryset_by_model_instance(obj).count() + 1 # +1 for creation + + +class WikiLinkSerializer(serializers.LightSerializer): + id = Field() + project = Field(attr="project_id") + title = Field() + href = Field() + order = Field() diff --git a/taiga/projects/wiki/utils.py b/taiga/projects/wiki/utils.py new file mode 100644 index 000000000..538e19ae5 --- /dev/null +++ b/taiga/projects/wiki/utils.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False): + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/wiki/validators.py b/taiga/projects/wiki/validators.py new file mode 100644 index 000000000..20ca34a7b --- /dev/null +++ b/taiga/projects/wiki/validators.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import validators +from taiga.base.api import serializers +from taiga.projects.notifications.validators import WatchersValidator + +from . import models + + +class WikiPageValidator(WatchersValidator, validators.ModelValidator): + slug = serializers.CharField() + + class Meta: + model = models.WikiPage + read_only_fields = ('modified_date', 'created_date', 'owner') + + +class WikiLinkValidator(validators.ModelValidator): + class Meta: + model = models.WikiLink + read_only_fields = ('href',) diff --git a/taiga/routers.py b/taiga/routers.py new file mode 100644 index 000000000..b2281bcad --- /dev/null +++ b/taiga/routers.py @@ -0,0 +1,326 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import routers +from django.conf import settings + +router = routers.DefaultRouter(trailing_slash=False) + +# Locales +from taiga.locale.api import LocalesViewSet + +router.register(r"locales", LocalesViewSet, base_name="locales") + + +# Users & Roles +from taiga.auth.api import AuthViewSet +from taiga.users.api import UsersViewSet +from taiga.users.api import RolesViewSet + +router.register(r"auth", AuthViewSet, base_name="auth") +router.register(r"users", UsersViewSet, base_name="users") +router.register(r"roles", RolesViewSet, base_name="roles") + + +# User Storage +from taiga.userstorage.api import StorageEntriesViewSet + +router.register(r"user-storage", StorageEntriesViewSet, base_name="user-storage") + + +# 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\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 +from taiga.projects.api import ProjectViewSet +from taiga.projects.api import ProjectFansViewSet +from taiga.projects.api import ProjectWatchersViewSet +from taiga.projects.api import MembershipViewSet +from taiga.projects.api import InvitationViewSet +from taiga.projects.api import EpicStatusViewSet +from taiga.projects.api import UserStoryStatusViewSet +from taiga.projects.api import PointsViewSet +from taiga.projects.api import SwimlaneViewSet +from taiga.projects.api import SwimlaneUserStoryStatusViewSet +from taiga.projects.api import UserStoryDueDateViewSet +from taiga.projects.api import TaskStatusViewSet +from taiga.projects.api import TaskDueDateViewSet +from taiga.projects.api import IssueStatusViewSet +from taiga.projects.api import IssueTypeViewSet +from taiga.projects.api import IssueDueDateViewSet +from taiga.projects.api import PriorityViewSet +from taiga.projects.api import SeverityViewSet +from taiga.projects.api import ProjectTemplateViewSet + +router.register(r"projects", ProjectViewSet, base_name="projects") +router.register(r"projects/(?P\d+)/fans", ProjectFansViewSet, base_name="project-fans") +router.register(r"projects/(?P\d+)/watchers", ProjectWatchersViewSet, base_name="project-watchers") +router.register(r"project-templates", ProjectTemplateViewSet, base_name="project-templates") +router.register(r"memberships", MembershipViewSet, base_name="memberships") +router.register(r"invitations", InvitationViewSet, base_name="invitations") +router.register(r"epic-statuses", EpicStatusViewSet, base_name="epic-statuses") +router.register(r"userstory-statuses", UserStoryStatusViewSet, base_name="userstory-statuses") +router.register(r"points", PointsViewSet, base_name="points") +router.register(r"swimlanes", SwimlaneViewSet, base_name="swimlanes") +router.register(r"swimlane-userstory-statuses", SwimlaneUserStoryStatusViewSet, base_name="swimlane-userstory-statuses") +router.register(r"userstory-due-dates", UserStoryDueDateViewSet, base_name="userstory-due-dates") +router.register(r"task-statuses", TaskStatusViewSet, base_name="task-statuses") +router.register(r"task-due-dates", TaskDueDateViewSet, base_name="task-due-dates") +router.register(r"issue-statuses", IssueStatusViewSet, base_name="issue-statuses") +router.register(r"issue-types", IssueTypeViewSet, base_name="issue-types") +router.register(r"issue-due-dates", IssueDueDateViewSet, base_name="issue-due-dates") +router.register(r"priorities", PriorityViewSet, base_name="priorities") +router.register(r"severities",SeverityViewSet , base_name="severities") + + +# Custom Attributes +from taiga.projects.custom_attributes.api import EpicCustomAttributeViewSet +from taiga.projects.custom_attributes.api import UserStoryCustomAttributeViewSet +from taiga.projects.custom_attributes.api import TaskCustomAttributeViewSet +from taiga.projects.custom_attributes.api import IssueCustomAttributeViewSet + +from taiga.projects.custom_attributes.api import EpicCustomAttributesValuesViewSet +from taiga.projects.custom_attributes.api import UserStoryCustomAttributesValuesViewSet +from taiga.projects.custom_attributes.api import TaskCustomAttributesValuesViewSet +from taiga.projects.custom_attributes.api import IssueCustomAttributesValuesViewSet + +router.register(r"epic-custom-attributes", EpicCustomAttributeViewSet, + base_name="epic-custom-attributes") +router.register(r"userstory-custom-attributes", UserStoryCustomAttributeViewSet, + base_name="userstory-custom-attributes") +router.register(r"task-custom-attributes", TaskCustomAttributeViewSet, + base_name="task-custom-attributes") +router.register(r"issue-custom-attributes", IssueCustomAttributeViewSet, + base_name="issue-custom-attributes") + +router.register(r"epics/custom-attributes-values", EpicCustomAttributesValuesViewSet, + base_name="epic-custom-attributes-values") +router.register(r"userstories/custom-attributes-values", UserStoryCustomAttributesValuesViewSet, + base_name="userstory-custom-attributes-values") +router.register(r"tasks/custom-attributes-values", TaskCustomAttributesValuesViewSet, + base_name="task-custom-attributes-values") +router.register(r"issues/custom-attributes-values", IssueCustomAttributesValuesViewSet, + base_name="issue-custom-attributes-values") + + +# Search +from taiga.searches.api import SearchViewSet + +router.register(r"search", SearchViewSet, base_name="search") + + +# Resolver +from taiga.projects.references.api import ResolverViewSet + +router.register(r"resolver", ResolverViewSet, base_name="resolver") + + +# Attachments +from taiga.projects.attachments.api import EpicAttachmentViewSet +from taiga.projects.attachments.api import UserStoryAttachmentViewSet +from taiga.projects.attachments.api import IssueAttachmentViewSet +from taiga.projects.attachments.api import TaskAttachmentViewSet +from taiga.projects.attachments.api import WikiAttachmentViewSet + +router.register(r"epics/attachments", EpicAttachmentViewSet, + base_name="epic-attachments") +router.register(r"userstories/attachments", UserStoryAttachmentViewSet, + base_name="userstory-attachments") +router.register(r"tasks/attachments", TaskAttachmentViewSet, + base_name="task-attachments") +router.register(r"issues/attachments", IssueAttachmentViewSet, + base_name="issue-attachments") +router.register(r"wiki/attachments", WikiAttachmentViewSet, + base_name="wiki-attachments") + + +# Project components +from taiga.projects.milestones.api import MilestoneViewSet +from taiga.projects.milestones.api import MilestoneWatchersViewSet + +from taiga.projects.epics.api import EpicViewSet +from taiga.projects.epics.api import EpicRelatedUserStoryViewSet +from taiga.projects.epics.api import EpicVotersViewSet +from taiga.projects.epics.api import EpicWatchersViewSet + +from taiga.projects.userstories.api import UserStoryViewSet +from taiga.projects.userstories.api import UserStoryVotersViewSet +from taiga.projects.userstories.api import UserStoryWatchersViewSet + +from taiga.projects.tasks.api import TaskViewSet +from taiga.projects.tasks.api import TaskVotersViewSet +from taiga.projects.tasks.api import TaskWatchersViewSet + +from taiga.projects.issues.api import IssueViewSet +from taiga.projects.issues.api import IssueVotersViewSet +from taiga.projects.issues.api import IssueWatchersViewSet + +from taiga.projects.wiki.api import WikiViewSet +from taiga.projects.wiki.api import WikiLinkViewSet +from taiga.projects.wiki.api import WikiWatchersViewSet + +router.register(r"milestones", MilestoneViewSet, + base_name="milestones") +router.register(r"milestones/(?P\d+)/watchers", MilestoneWatchersViewSet, + base_name="milestone-watchers") + +router.register(r"epics", EpicViewSet, base_name="epics")\ + .register(r"related_userstories", EpicRelatedUserStoryViewSet, + base_name="epics-related-userstories", + parents_query_lookups=["epic"]) + +router.register(r"epics/(?P\d+)/voters", EpicVotersViewSet, + base_name="epic-voters") +router.register(r"epics/(?P\d+)/watchers", EpicWatchersViewSet, + base_name="epic-watchers") + +router.register(r"userstories", UserStoryViewSet, + base_name="userstories") +router.register(r"userstories/(?P\d+)/voters", UserStoryVotersViewSet, + base_name="userstory-voters") +router.register(r"userstories/(?P\d+)/watchers", UserStoryWatchersViewSet, + base_name="userstory-watchers") + +router.register(r"tasks", TaskViewSet, + base_name="tasks") +router.register(r"tasks/(?P\d+)/voters", TaskVotersViewSet, + base_name="task-voters") +router.register(r"tasks/(?P\d+)/watchers", TaskWatchersViewSet, + base_name="task-watchers") + +router.register(r"issues", IssueViewSet, + base_name="issues") +router.register(r"issues/(?P\d+)/voters", IssueVotersViewSet, + base_name="issue-voters") +router.register(r"issues/(?P\d+)/watchers", IssueWatchersViewSet, + base_name="issue-watchers") + +router.register(r"wiki", WikiViewSet, + base_name="wiki") +router.register(r"wiki/(?P\d+)/watchers", WikiWatchersViewSet, + base_name="wiki-watchers") +router.register(r"wiki-links", WikiLinkViewSet, + base_name="wiki-links") + + +# Delete owned projects +from taiga.projects.api import DeleteOwnProjectsViewSet + +router.register(r"delete-owned-projects", DeleteOwnProjectsViewSet, + base_name="delete-owned-projects") + + +# History & Components +from taiga.projects.history.api import EpicHistory +from taiga.projects.history.api import UserStoryHistory +from taiga.projects.history.api import TaskHistory +from taiga.projects.history.api import IssueHistory +from taiga.projects.history.api import WikiHistory + +router.register(r"history/epic", EpicHistory, base_name="epic-history") +router.register(r"history/userstory", UserStoryHistory, base_name="userstory-history") +router.register(r"history/task", TaskHistory, base_name="task-history") +router.register(r"history/issue", IssueHistory, base_name="issue-history") +router.register(r"history/wiki", WikiHistory, base_name="wiki-history") + +# Contact +from taiga.projects.contact.api import ContactViewSet +router.register(r"contact", ContactViewSet, base_name="contact") + + +# Timelines +from taiga.timeline.api import ProfileTimeline +from taiga.timeline.api import UserTimeline +from taiga.timeline.api import ProjectTimeline + +router.register(r"timeline/profile", ProfileTimeline, base_name="profile-timeline") +router.register(r"timeline/user", UserTimeline, base_name="user-timeline") +router.register(r"timeline/project", ProjectTimeline, base_name="project-timeline") + + +# Webhooks +from taiga.webhooks.api import WebhookViewSet +from taiga.webhooks.api import WebhookLogViewSet + +router.register(r"webhooks", WebhookViewSet, base_name="webhooks") +router.register(r"webhooklogs", WebhookLogViewSet, base_name="webhooklogs") + + +# GitHub webhooks +from taiga.hooks.github.api import GitHubViewSet + +router.register(r"github-hook", GitHubViewSet, base_name="github-hook") + + +# Gitlab webhooks +from taiga.hooks.gitlab.api import GitLabViewSet + +router.register(r"gitlab-hook", GitLabViewSet, base_name="gitlab-hook") + + +# Bitbucket webhooks +from taiga.hooks.bitbucket.api import BitBucketViewSet + +router.register(r"bitbucket-hook", BitBucketViewSet, base_name="bitbucket-hook") + + +# Gogs webhooks +from taiga.hooks.gogs.api import GogsViewSet + +router.register(r"gogs-hook", GogsViewSet, base_name="gogs-hook") + + +# Importer +from taiga.export_import.api import ProjectImporterViewSet, ProjectExporterViewSet + +router.register(r"importer", ProjectImporterViewSet, base_name="importer") +router.register(r"exporter", ProjectExporterViewSet, base_name="exporter") + + +# External apps +from taiga.external_apps.api import Application, ApplicationToken + +router.register(r"applications", Application, base_name="applications") +router.register(r"application-tokens", ApplicationToken, base_name="application-tokens") + +# Third party importers +if settings.IMPORTERS.get('trello', {}).get('active', False): + from taiga.importers.trello.api import TrelloImporterViewSet + router.register(r"importers/trello", TrelloImporterViewSet, base_name="importers-trello") + +if settings.IMPORTERS.get('jira', {}).get('active', False): + from taiga.importers.jira.api import JiraImporterViewSet + router.register(r"importers/jira", JiraImporterViewSet, base_name="importers-jira") + +if settings.IMPORTERS.get('github', {}).get('active', False): + from taiga.importers.github.api import GithubImporterViewSet + router.register(r"importers/github", GithubImporterViewSet, base_name="importers-github") + +if settings.IMPORTERS.get('asana', {}).get('active', False): + from taiga.importers.asana.api import AsanaImporterViewSet + router.register(r"importers/asana", AsanaImporterViewSet, base_name="importers-asana") + + +# Stats +# - see taiga.stats.routers and taiga.stats.apps + + +# Feedback +# - see taiga.feedback.routers and taiga.feedback.apps diff --git a/taiga/searches/__init__.py b/taiga/searches/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/searches/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/searches/api.py b/taiga/searches/api.py new file mode 100644 index 000000000..1855fc4db --- /dev/null +++ b/taiga/searches/api.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps + +from taiga.base.api import viewsets + +from taiga.base import response +from taiga.base.api.utils import get_object_or_error +from taiga.permissions.services import user_has_perm + +from . import services +from . import serializers + + +from concurrent import futures + +class SearchViewSet(viewsets.ViewSet): + def list(self, request, **kwargs): + text = request.QUERY_PARAMS.get('text', "") + project_id = request.QUERY_PARAMS.get('project', None) + + project = self._get_project(project_id) + + result = {} + with futures.ThreadPoolExecutor(max_workers=4) as executor: + futures_list = [] + if user_has_perm(request.user, "view_epics", project): + epics_future = executor.submit(self._search_epics, project, text) + epics_future.result_key = "epics" + futures_list.append(epics_future) + if user_has_perm(request.user, "view_us", project): + uss_future = executor.submit(self._search_user_stories, project, text) + uss_future.result_key = "userstories" + futures_list.append(uss_future) + if user_has_perm(request.user, "view_tasks", project): + tasks_future = executor.submit(self._search_tasks, project, text) + tasks_future.result_key = "tasks" + futures_list.append(tasks_future) + if user_has_perm(request.user, "view_issues", project): + issues_future = executor.submit(self._search_issues, project, text) + issues_future.result_key = "issues" + futures_list.append(issues_future) + if user_has_perm(request.user, "view_wiki_pages", project): + wiki_pages_future = executor.submit(self._search_wiki_pages, project, text) + wiki_pages_future.result_key = "wikipages" + futures_list.append(wiki_pages_future) + + for future in futures.as_completed(futures_list): + data = [] + try: + data = future.result() + except Exception as exc: + print('%s generated an exception: %s' % (future.result_key, exc)) + finally: + result[future.result_key] = data + + result["count"] = sum(map(lambda x: len(x), result.values())) + return response.Ok(result) + + def _get_project(self, project_id): + project_model = apps.get_model("projects", "Project") + return get_object_or_error(project_model, self.request.user, pk=project_id) + + def _search_epics(self, project, text): + queryset = services.search_epics(project, text) + serializer = serializers.EpicSearchResultsSerializer(queryset, many=True) + return serializer.data + + def _search_user_stories(self, project, text): + queryset = services.search_user_stories(project, text) + serializer = serializers.UserStorySearchResultsSerializer(queryset, many=True) + return serializer.data + + def _search_tasks(self, project, text): + queryset = services.search_tasks(project, text) + serializer = serializers.TaskSearchResultsSerializer(queryset, many=True) + return serializer.data + + def _search_issues(self, project, text): + queryset = services.search_issues(project, text) + serializer = serializers.IssueSearchResultsSerializer(queryset, many=True) + return serializer.data + + def _search_wiki_pages(self, project, text): + queryset = services.search_wiki_pages(project, text) + serializer = serializers.WikiPageSearchResultsSerializer(queryset, many=True) + return serializer.data diff --git a/taiga/searches/models.py b/taiga/searches/models.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/searches/models.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/searches/serializers.py b/taiga/searches/serializers.py new file mode 100644 index 000000000..58c5f6eb1 --- /dev/null +++ b/taiga/searches/serializers.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField + + +class EpicSearchResultsSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + status = Field(attr="status_id") + assigned_to = Field(attr="assigned_to_id") + + +class UserStorySearchResultsSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + status = Field(attr="status_id") + total_points = MethodField() + milestone_name = MethodField() + milestone_slug = MethodField() + + def get_milestone_name(self, obj): + return obj.milestone.name if obj.milestone else None + + def get_milestone_slug(self, obj): + return obj.milestone.slug if obj.milestone else None + + def get_total_points(self, obj): + assert hasattr(obj, "total_points_attr"), \ + "instance must have a total_points_attr attribute" + + return obj.total_points_attr + + +class TaskSearchResultsSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + status = Field(attr="status_id") + assigned_to = Field(attr="assigned_to_id") + + +class IssueSearchResultsSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + status = Field(attr="status_id") + assigned_to = Field(attr="assigned_to_id") + + +class WikiPageSearchResultsSerializer(serializers.LightSerializer): + id = Field() + slug = Field() diff --git a/taiga/searches/services.py b/taiga/searches/services.py new file mode 100644 index 000000000..44606a435 --- /dev/null +++ b/taiga/searches/services.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.conf import settings +from taiga.base.utils.db import to_tsquery +from taiga.projects.userstories.utils import attach_total_points + +MAX_RESULTS = getattr(settings, "SEARCHES_MAX_RESULTS", 150) + + +def search_epics(project, text): + model = apps.get_model("epics", "Epic") + queryset = model.objects.filter(project_id=project.pk) + table = "epics_epic" + return _search_items(queryset, table, text) + + +def search_user_stories(project, text): + model = apps.get_model("userstories", "UserStory") + queryset = model.objects.filter(project_id=project.pk) + table = "userstories_userstory" + return _search_items(queryset, table, text) + + +def search_tasks(project, text): + model = apps.get_model("tasks", "Task") + queryset = model.objects.filter(project_id=project.pk) + table = "tasks_task" + return _search_items(queryset, table, text) + + +def search_issues(project, text): + model = apps.get_model("issues", "Issue") + queryset = model.objects.filter(project_id=project.pk) + table = "issues_issue" + return _search_items(queryset, table, text) + + +def search_wiki_pages(project, text): + model = apps.get_model("wiki", "WikiPage") + queryset = model.objects.filter(project_id=project.pk) + tsquery = "to_tsquery('simple', %s)" + tsvector = """ + setweight(to_tsvector('simple', coalesce(wiki_wikipage.slug)), 'A') || + setweight(to_tsvector('simple', coalesce(wiki_wikipage.content)), 'B') + """ + + return _search_by_query(queryset, tsquery, tsvector, text) + + +def _search_items(queryset, table, text): + tsquery = "to_tsquery('simple', %s)" + tsvector = """ + setweight(to_tsvector('simple', + coalesce({table}.subject) || ' ' || + coalesce({table}.ref)), 'A') || + setweight(to_tsvector('simple', coalesce(inmutable_array_to_string({table}.tags))), 'B') || + setweight(to_tsvector('simple', coalesce({table}.description)), 'C') + """.format(table=table) + return _search_by_query(queryset, tsquery, tsvector, text) + + +def _search_by_query(queryset, tsquery, tsvector, text): + select = { + "rank": "ts_rank({tsvector},{tsquery})".format(tsquery=tsquery, + tsvector=tsvector), + } + order_by = ["-rank", ] + where = ["{tsvector} @@ {tsquery}".format(tsquery=tsquery, + tsvector=tsvector), ] + + if text: + queryset = queryset.extra(select=select, + select_params=[to_tsquery(text)], + where=where, + params=[to_tsquery(text)], + order_by=order_by) + + queryset = attach_total_points(queryset) + return queryset[:MAX_RESULTS] diff --git a/taiga/stats/__init__.py b/taiga/stats/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/stats/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/stats/api.py b/taiga/stats/api.py new file mode 100644 index 000000000..62475e4fc --- /dev/null +++ b/taiga/stats/api.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from collections import OrderedDict + +from django.conf import settings +from django.views.decorators.cache import cache_page + +from taiga.base import response +from taiga.base.api import viewsets + +from . import permissions +from . import services + + +CACHE_TIMEOUT = getattr(settings, "STATS_CACHE_TIMEOUT", 0) + + +class BaseStatsViewSet(viewsets.ViewSet): + @property + def _cache_timeout(self): + return CACHE_TIMEOUT + + def dispatch(self, *args, **kwargs): + return cache_page(self._cache_timeout)(super().dispatch)(*args, **kwargs) + + +class SystemStatsViewSet(BaseStatsViewSet): + permission_classes = (permissions.SystemStatsPermission,) + + def list(self, request, **kwargs): + stats = OrderedDict() + stats["users"] = services.get_users_public_stats() + stats["projects"] = services.get_projects_public_stats() + stats["userstories"] = services.get_user_stories_public_stats() + return response.Ok(stats) + + +class DiscoverStatsViewSet(BaseStatsViewSet): + permission_classes = (permissions.DiscoverStatsPermission,) + + def list(self, request, **kwargs): + stats = OrderedDict() + stats["projects"] = services.get_projects_discover_stats(user=request.user) + return response.Ok(stats) diff --git a/taiga/stats/apps.py b/taiga/stats/apps.py new file mode 100644 index 000000000..29db97fec --- /dev/null +++ b/taiga/stats/apps.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig +from django.apps import apps +from django.urls import include, path + +from .routers import router + + +class StatsAppConfig(AppConfig): + name = "taiga.stats" + verbose_name = "Stats" + + def ready(self): + from taiga.urls import urlpatterns + urlpatterns.append(path('api/v1/', include(router.urls))) diff --git a/taiga/stats/permissions.py b/taiga/stats/permissions.py new file mode 100644 index 000000000..b982bb475 --- /dev/null +++ b/taiga/stats/permissions.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import permissions + + +class SystemStatsPermission(permissions.TaigaResourcePermission): + global_perms = permissions.AllowAny() + + +class DiscoverStatsPermission(permissions.TaigaResourcePermission): + global_perms = permissions.AllowAny() diff --git a/taiga/stats/routers.py b/taiga/stats/routers.py new file mode 100644 index 000000000..6ed3e37ab --- /dev/null +++ b/taiga/stats/routers.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings + +from taiga.base import routers + +from . import api + + +router = routers.DefaultRouter(trailing_slash=False) + +if settings.STATS_ENABLED: + router.register(r"stats/system", api.SystemStatsViewSet, base_name="system-stats") + +router.register(r"stats/discover", api.DiscoverStatsViewSet, base_name="discover-stats") diff --git a/taiga/stats/services.py b/taiga/stats/services.py new file mode 100644 index 000000000..204de429c --- /dev/null +++ b/taiga/stats/services.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.contrib.auth import get_user_model +from django.db.models import Count +from django.db.models import Q +from django.utils import timezone + +from datetime import timedelta +from collections import OrderedDict + + +########################################################################### +# Public Stats +########################################################################### + +def get_users_public_stats(): + model = get_user_model() + queryset = model.objects.filter(is_active=True, is_system=False) + stats = OrderedDict() + + today = timezone.now() + yesterday = today - timedelta(days=1) + seven_days_ago = yesterday - timedelta(days=7) + a_year_ago = today - timedelta(days=365) + + stats["total"] = queryset.count() + stats["today"] = queryset.filter(date_joined__year=today.year, + date_joined__month=today.month, + date_joined__day=today.day).count() + stats["average_last_seven_days"] = (queryset.filter(date_joined__range=(seven_days_ago, yesterday)) + .count()) / 7 + stats["average_last_five_working_days"] = (queryset.filter(date_joined__range=(seven_days_ago, yesterday)) + .exclude(Q(date_joined__week_day=1) | + Q(date_joined__week_day=7)) + .count()) / 5 + + # Graph: users last year + # increments -> + # SELECT date_trunc('week', "filtered_users"."date_joined") AS "week", + # count(*) + # FROM (SELECT * + # FROM "users_user" + # WHERE "users_user"."is_active" = TRUE + # AND "users_user"."is_system" = FALSE + # AND "users_user"."date_joined" >= %s) AS "filtered_users" + # GROUP BY "week" + # ORDER BY "week"; + increments = (queryset.filter(date_joined__gte=a_year_ago) + .extra({"week": "date_trunc('week', date_joined)"}) + .values("week") + .order_by("week") + .annotate(count=Count("id"))) + + counts_last_year_per_week = OrderedDict() + sumatory = queryset.filter(date_joined__lt=increments[0]["week"]).count() + for inc in increments: + sumatory += inc["count"] + counts_last_year_per_week[str(inc["week"].date())] = sumatory + + stats["counts_last_year_per_week"] = counts_last_year_per_week + + return stats + + +def get_projects_public_stats(): + model = apps.get_model("projects", "Project") + queryset = model.objects.all() + stats = OrderedDict() + + today = timezone.now() + yesterday = today - timedelta(days=1) + seven_days_ago = yesterday - timedelta(days=7) + + stats["total"] = queryset.count() + stats["today"] = queryset.filter(created_date__year=today.year, + created_date__month=today.month, + created_date__day=today.day).count() + stats["average_last_seven_days"] = (queryset.filter(created_date__range=(seven_days_ago, yesterday)) + .count()) / 7 + stats["average_last_five_working_days"] = (queryset.filter(created_date__range=(seven_days_ago, yesterday)) + .exclude(Q(created_date__week_day=1) | + Q(created_date__week_day=7)) + .count()) / 5 + + stats["total_with_backlog"] = (queryset.filter(is_backlog_activated=True, + is_kanban_activated=False) + .count()) + stats["percent_with_backlog"] = stats["total_with_backlog"] * 100 / stats["total"] + + stats["total_with_kanban"] = (queryset.filter(is_backlog_activated=False, + is_kanban_activated=True) + .count()) + stats["percent_with_kanban"] = stats["total_with_kanban"] * 100 / stats["total"] + + stats["total_with_backlog_and_kanban"] = (queryset.filter(is_backlog_activated=True, + is_kanban_activated=True) + .count()) + stats["percent_with_backlog_and_kanban"] = stats["total_with_backlog_and_kanban"] * 100 / stats["total"] + + return stats + + +def get_user_stories_public_stats(): + model = apps.get_model("userstories", "UserStory") + queryset = model.objects.all() + stats = OrderedDict() + + today = timezone.now() + yesterday = today - timedelta(days=1) + seven_days_ago = yesterday - timedelta(days=7) + + stats["total"] = queryset.count() + stats["today"] = queryset.filter(created_date__year=today.year, + created_date__month=today.month, + created_date__day=today.day).count() + stats["average_last_seven_days"] = (queryset.filter(created_date__range=(seven_days_ago, yesterday)) + .count()) / 7 + stats["average_last_five_working_days"] = (queryset.filter(created_date__range=(seven_days_ago, yesterday)) + .exclude(Q(created_date__week_day=1) | + Q(created_date__week_day=7)) + .count()) / 5 + + return stats + +########################################################################### +# Discover Stats +########################################################################### + +def get_projects_discover_stats(user=None): + model = apps.get_model("projects", "Project") + queryset = model.objects.all() + stats = OrderedDict() + + # Get Public (visible) projects + queryset = queryset.filter(Q(is_private=False) | + Q(is_private=True, anon_permissions__contains=["view_project"])) + + stats["total"] = queryset.count() + + return stats diff --git a/taiga/telemetry/__init__.py b/taiga/telemetry/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/telemetry/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/telemetry/apps.py b/taiga/telemetry/apps.py new file mode 100644 index 000000000..91407964b --- /dev/null +++ b/taiga/telemetry/apps.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig + + +class TelemetryAppConfig(AppConfig): + name = "taiga.telemetry" + verbose_name = "Telemetry" diff --git a/taiga/telemetry/migrations/0001_initial.py b/taiga/telemetry/migrations/0001_initial.py new file mode 100644 index 000000000..eb4f0a19d --- /dev/null +++ b/taiga/telemetry/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.16 on 2020-11-20 10:05 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='InstanceTelemetry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('instance_id', models.CharField(max_length=100, verbose_name='instance id')), + ('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created at')), + ], + options={ + 'verbose_name': 'instance telemetry', + 'verbose_name_plural': 'instances telemetries', + }, + ), + ] diff --git a/taiga/telemetry/migrations/__init__.py b/taiga/telemetry/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/telemetry/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/telemetry/models.py b/taiga/telemetry/models.py new file mode 100644 index 000000000..e4ab331ab --- /dev/null +++ b/taiga/telemetry/models.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class InstanceTelemetry(models.Model): + instance_id = models.CharField( + null=False, + blank=False, + max_length=100, + verbose_name=_("instance id") + ) + created_at = models.DateTimeField(default=timezone.now, + verbose_name=_("created at")) + + class Meta: + verbose_name = "instance telemetry" + verbose_name_plural = "instances telemetries" + + def __str__(self): + return self.instance_id diff --git a/taiga/telemetry/services.py b/taiga/telemetry/services.py new file mode 100644 index 000000000..1f3dc7573 --- /dev/null +++ b/taiga/telemetry/services.py @@ -0,0 +1,333 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime +import uuid + +from django.db.models import Aggregate +from django.db.models import Avg +from django.db.models import Count +from django.db.models import F, Func +from django.db.models import FloatField, IntegerField +from django.db.models import Q +from django.db.models.functions import Coalesce +from django.contrib.contenttypes.models import ContentType + +from taiga.projects.history.models import HistoryEntry +from taiga.projects.milestones.models import Milestone +from taiga.projects.models import Project +from taiga.projects.notifications.models import Watched +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task +from taiga.telemetry.models import InstanceTelemetry +from taiga.users.models import User + + + +class Median(Aggregate): + function = 'PERCENTILE_CONT' + name = 'median' + output_field = FloatField() + template = '%(function)s(0.5) WITHIN GROUP (ORDER BY %(expressions)s)' + + +def get_or_create_instance_info(): + instance = InstanceTelemetry.objects.first() + if not instance: + instance = InstanceTelemetry.objects.create( + instance_id=uuid.uuid4().hex + ) + + return instance + + +def generate_platform_data(): + total_uss = UserStory.objects.count() + + pd = {} + + # number of projects + pd['tt_projects'] = Project.objects.count() + + # number of private projects + pd['tt_projects_private'] = Project.objects.filter( + is_private=True + ).count() + + # number of public projects + pd['tt_projects_public'] = Project.objects.filter( + is_private=False + ).count() + + # number of projects with scrum active and kanban inactive + pd['tt_projects_only_scrum'] = Project.objects.filter( + is_backlog_activated=True, is_kanban_activated=False + ).count() + + # number of projects with both scrum and kanban active + pd['tt_projects_kanban_scrum'] = Project.objects.filter( + is_backlog_activated=True, is_kanban_activated=True + ).count() + + # number of projects with none scrum and kanban active + pd['tt_projects_no_kanban_no_scrum'] = Project.objects.filter( + is_backlog_activated=False, is_kanban_activated=False + ).count() + + # number of projects with kaban active and scrum inactive + pd['tt_projects_only_kanban'] = Project.objects.filter( + is_backlog_activated=False, is_kanban_activated=True + ).count() + + # number of projects with kaban active and at least 1 swimlane + pd['tt_projects_swimlanes_active_kanban'] = Project.objects.annotate( + total_swimlanes=Count('swimlanes') + ).filter( + total_swimlanes__gte=1, is_kanban_activated=True + ).count() + + # number of projects with issues active + pd['tt_projects_issues'] = Project.objects.filter( + is_issues_activated=True + ).count() + + # number of projects with epics active + pd['tt_projects_epics'] = Project.objects.filter( + is_epics_activated=True + ).count() + + # number of projects with wiki active + pd['tt_projects_wiki'] = Project.objects.filter( + is_wiki_activated=True + ).count() + + # number of projects with at least 1 tag + pd['tt_projects_tags'] = Project.objects.filter( + tags_colors__len__gt=0 + ).count() + + # number of projects with at least 1 custom field + pd['tt_projects_custom_fields'] = Project.objects.annotate( + total_custom_fields=Count('epiccustomattributes', distinct=True) + \ + Count('issuecustomattributes', distinct=True) + \ + Count('taskcustomattributes', distinct=True) + \ + Count('userstorycustomattributes', distinct=True) + ).exclude( + total_custom_fields=0 + ).count() + + # number of users + pd['tt_users'] = User.objects.exclude( + is_system=True + ).count() + + # number of active users + pd['tt_users_active'] = User.objects.filter( + is_active=True + ).exclude( + is_system=True + ).count() + + # average and median of epics in projects with module epics active + epics = Project.objects.filter( + is_epics_activated=True + ).annotate( + total_epics=Count('epics') + ).aggregate( + avg_epics_project=Avg('total_epics'), + median_epics_project=Median('total_epics') + ) + pd['tt_avg_epics_project'] = epics['avg_epics_project'] + pd['tt_median_epics_project'] = epics['median_epics_project'] + + # average and median of userstories in projects with module scrum or kanban active + userstories = Project.objects.filter( + Q(is_kanban_activated=True) | Q(is_backlog_activated=True) + ).annotate( + total_user_stories=Count('user_stories') + ).aggregate( + avg_uss_project=Avg('total_user_stories'), + median_userstories_project=Median('total_user_stories') + ) + pd['tt_avg_uss_project'] = userstories['avg_uss_project'] + pd['tt_median_userstories_project'] = userstories['median_userstories_project'] + + # average and median of tasks in projects with module scrum or kanban active + tasks = Project.objects.filter( + Q(is_kanban_activated=True) | Q(is_backlog_activated=True) + ).annotate( + total_tasks=Count('tasks') + ).aggregate( + avg_tasks_project=Avg('total_tasks'), + median_tasks_project=Median('total_tasks') + ) + pd['tt_avg_tasks_project'] = tasks['avg_tasks_project'] + pd['tt_median_tasks_project'] = tasks['median_tasks_project'] + + # average and median of wiki pages in projects with wiki active + wiki_pages = Project.objects.filter( + is_wiki_activated=True + ).annotate( + total_wiki_pages=Count('wiki_pages') + ).aggregate( + avg_wiki_pages_project=Avg('total_wiki_pages'), + median_wiki_pages_project=Median('total_wiki_pages') + ) + + pd['tt_avg_wiki_pages_project'] = wiki_pages['avg_wiki_pages_project'] + pd['tt_median_wiki_pages_project'] = wiki_pages['median_wiki_pages_project'] + + # average and of issues in projects with module issues active + issues = Project.objects.filter( + is_issues_activated=True + ).annotate( + total_issues=Count('issues') + ).aggregate( + avg_issues_project=Avg('total_issues'), + median_issues_project=Median('total_issues') + ) + pd['tt_avg_issues_project'] = issues['avg_issues_project'] + pd['tt_median_issues_project'] = issues['median_issues_project'] + + # average and median of swimlanes in projects with kanban + swimlanes = Project.objects.annotate( + total_swimlanes=Count('swimlanes') + ).filter( + is_kanban_activated=True + ).aggregate( + avg_swimlanes_project=Avg('total_swimlanes'), + median_swimlanes_project=Median('total_swimlanes') + ) + pd['tt_avg_swimlanes_project'] = swimlanes['avg_swimlanes_project'] + pd['tt_median_swimlanes_project'] = swimlanes['median_swimlanes_project'] + + # average and median of tags in projects with at least one tag + tags = Project.objects.annotate( + total_tags=Func(F('tags_colors'), 1, function='array_length', output_field=IntegerField()) + ).aggregate( + avg_tags_project=Avg('total_tags'), + median_tags_project=Median('total_tags') + ) + pd['tt_avg_tags_project'] = tags['avg_tags_project'] + pd['tt_median_tags_project'] = tags['median_tags_project'] + + # average and median of custom fields in projects with at least 1 custom field + custom_fields = Project.objects.annotate( + total_custom_fields=Count('epiccustomattributes', distinct=True) + \ + Count('issuecustomattributes', distinct=True) + \ + Count('taskcustomattributes', distinct=True) + \ + Count('userstorycustomattributes', distinct=True) + ).exclude( + total_custom_fields=0 + ).aggregate( + avg_custom_fields_project=Avg('total_custom_fields'), + median_custom_fields_project=Median('total_custom_fields') + ) + pd['tt_avg_custom_fields_project'] = custom_fields['avg_custom_fields_project'] + pd['tt_median_custom_fields_project'] = custom_fields['median_custom_fields_project'] + + # average and median of members per project + members = Project.objects.annotate( + total_members=Count('members') + ).aggregate( + avg_members_project=Avg('total_members'), + median_members_project=Median('total_members') + ) + pd['tt_avg_members_project'] = members['avg_members_project'] + pd['tt_median_members_project'] = members['median_members_project'] + + # average and median of roles per project + roles = Project.objects.annotate( + total_roles=Count('roles') + ).aggregate( + avg_roles_project=Avg('total_roles'), + median_roles_project=Avg('total_roles') + ) + pd['tt_avg_roles_project'] = roles['avg_roles_project'] + pd['tt_median_roles_project'] = roles['median_roles_project'] + + # percent of user stories assigned + pd['tt_percent_uss_assigned'] = _get_tt_percent_uss_assigned(total_uss) + + # percent of user stories watched + pd['tt_percent_uss_watching'] = _get_tt_percent_uss_watched(total_uss) + + # percent of user stories with at least one comment + pd['tt_percent_uss_comments_gte_1'] = HistoryEntry.objects.filter( + key__startswith='userstories.userstory:', + comment__isnull=False + ).order_by( + 'key' + ).distinct( + 'key' + ).count() + + # average and median of sprints in projects with backlog activated + sprints = Project.objects.annotate( + total_sprints=Count('milestones') + ).filter( + is_backlog_activated=True + ).aggregate( + avg_sprints_project=Avg('total_sprints'), + median_sprints_project=Avg('total_sprints') + ) + pd['tt_avg_sprints_project'] = sprints['avg_sprints_project'] + pd['tt_median_sprints_project'] = sprints['median_sprints_project'] + + # average of uss per sprint + uss_sprint = Milestone.objects.annotate( + total_user_stories=Count('user_stories') + ).aggregate( + avg_uss_sprint=Avg('total_user_stories'), + median_uss_sprint=Avg('total_user_stories') + ) + pd['tt_avg_uss_sprint'] = uss_sprint['avg_uss_sprint'] + pd['tt_median_uss_sprint'] = uss_sprint['median_uss_sprint'] + + # number of edits + pd['tt_edits_today'] = HistoryEntry.objects.filter( + created_at__day=datetime.datetime.today().day + ).count() + + # number of new US + pd['tt_new_user_stories_today'] = UserStory.objects.filter( + created_date__day=datetime.datetime.today().day + ).count() + + # number of closed US + pd['tt_finished_user_stories_today'] = UserStory.objects.filter( + finish_date__day=datetime.datetime.today().day + ).count() + + # number of new tasks + pd['tt_new_tasks_today'] = Task.objects.filter( + created_date__day=datetime.datetime.today().day + ).count() + + # number of closed tasks + pd['tt_finished_tasks_today'] = Task.objects.filter( + finished_date__day=datetime.datetime.today().day + ).count() + + return pd + + +def _get_tt_percent_uss_assigned(total_uss): + if total_uss == 0: + return 0 + + assigned_uss = UserStory.objects.filter(assigned_users__isnull=False).count() + return assigned_uss * 100 / total_uss + + +def _get_tt_percent_uss_watched(total_uss): + if total_uss == 0: + return 0 + + content_type = ContentType.objects.get(model='userstory') + watched_uss = Watched.objects.filter(content_type=content_type).distinct('object_id').count() + return watched_uss * 100 / total_uss diff --git a/taiga/telemetry/tasks.py b/taiga/telemetry/tasks.py new file mode 100644 index 000000000..58cf536bf --- /dev/null +++ b/taiga/telemetry/tasks.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +import rudder_analytics + +import taiga +from taiga.celery import app +from taiga.telemetry import services + + + +@app.task +def send_telemetry(): + rudder_analytics.write_key = settings.RUDDER_WRITE_KEY + rudder_analytics.data_plane_url = settings.DATA_PLANE_URL + + instance = services.get_or_create_instance_info() + instance_host = f'{ settings.SITES["front"]["scheme"] }://{ settings.SITES["front"]["domain"] }{ settings.FORCE_SCRIPT_NAME }' + event = 'Daily telemetry' + + properties = { + **services.generate_platform_data(), + 'version': taiga.__version__, + 'running_since': instance.created_at, + 'instance_src': settings.INSTANCE_TYPE, + 'instance_host': instance_host + } + + rudder_analytics.track( + user_id=instance.instance_id, + event=event, + properties=properties + ) diff --git a/taiga/timeline/__init__.py b/taiga/timeline/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/timeline/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/timeline/api.py b/taiga/timeline/api.py new file mode 100644 index 000000000..628cf0b1c --- /dev/null +++ b/taiga/timeline/api.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +from taiga.base import response +from taiga.base.api import ReadOnlyListViewSet + +from . import serializers +from . import service +from . import permissions + + +class TimelineViewSet(ReadOnlyListViewSet): + serializer_class = serializers.TimelineSerializer + + content_type = None + + def get_content_type(self): + app_name, model = self.content_type.split(".", 1) + return ContentType.objects.get_by_natural_key(app_name, model) + + def get_queryset(self): + ct = self.get_content_type() + model_cls = ct.model_class() + + qs = model_cls.objects.all() + filtered_qs = self.filter_queryset(qs) + return filtered_qs + + def response_for_queryset(self, queryset): + # Switch between paginated or standard style responses + page = self.paginate_queryset(queryset) + if page is not None: + user_ids = list(set([obj.data.get("user", {}).get("id", None) for obj in page.object_list])) + User = get_user_model() + users = {u.id: u for u in User.objects.filter(id__in=user_ids)} + + for obj in page.object_list: + user_id = obj.data.get("user", {}).get("id", None) + obj._prefetched_user = users.get(user_id, None) + + serializer = self.get_pagination_serializer(page) + else: + serializer = self.get_serializer(queryset, many=True) + + return response.Ok(serializer.data) + + # Just for restframework! Because it raises + # 404 on main api root if this method not exists. + def list(self, request): + return response.NotFound() + + def get_timeline(self, obj): + raise NotImplementedError + + def retrieve(self, request, pk): + obj = self.get_object() + self.check_permissions(request, "retrieve", obj) + + qs = self.get_timeline(obj) + + if request.GET.get("only_relevant", None) is not None: + qs = qs.exclude(Q(event_type=["issues.issue.change", + "tasks.task.change", + "userstories.userstory.change", + "epics.epic.change", + "wiki.wikipage.change"]), + Q(data__values_diff={}) | + Q(data__values_diff__attachments__new=[])) + + qs = qs.exclude(event_type__in=["issues.issue.delete", + "tasks.task.delete", + "userstories.userstory.delete", + "epics.epic.delete", + "wiki.wikipage.delete", + "projects.project.change"]) + + return self.response_for_queryset(qs) + + +class ProfileTimeline(TimelineViewSet): + content_type = settings.AUTH_USER_MODEL.lower() + permission_classes = (permissions.UserTimelinePermission,) + + def get_timeline(self, user): + return service.get_profile_timeline(user, accessing_user=self.request.user) + + +class UserTimeline(TimelineViewSet): + content_type = settings.AUTH_USER_MODEL.lower() + permission_classes = (permissions.UserTimelinePermission,) + + def get_timeline(self, user): + return service.get_user_timeline(user, accessing_user=self.request.user) + + +class ProjectTimeline(TimelineViewSet): + content_type = "projects.project" + permission_classes = (permissions.ProjectTimelinePermission,) + + def get_timeline(self, project): + return service.get_project_timeline(project, accessing_user=self.request.user) diff --git a/taiga/timeline/apps.py b/taiga/timeline/apps.py new file mode 100644 index 000000000..72f13ad41 --- /dev/null +++ b/taiga/timeline/apps.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import AppConfig +from django.apps import apps +from django.contrib.auth import get_user_model +from django.db.models import signals + + +class TimelineAppConfig(AppConfig): + name = "taiga.timeline" + verbose_name = "Timeline" + + def ready(self): + from . import signals as handlers + + signals.post_save.connect(handlers.on_new_history_entry, + sender=apps.get_model("history", "HistoryEntry"), + dispatch_uid="timeline") + signals.post_save.connect(handlers.create_membership_push_to_timeline, + sender=apps.get_model("projects", "Membership")) + signals.pre_delete.connect(handlers.delete_membership_push_to_timeline, + sender=apps.get_model("projects", "Membership")) + signals.post_save.connect(handlers.create_user_push_to_timeline, + sender=get_user_model()) diff --git a/taiga/timeline/management/__init__.py b/taiga/timeline/management/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/timeline/management/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/timeline/management/commands/_clear_unnecessary_new_membership_entries.py b/taiga/timeline/management/commands/_clear_unnecessary_new_membership_entries.py new file mode 100644 index 000000000..198938b99 --- /dev/null +++ b/taiga/timeline/management/commands/_clear_unnecessary_new_membership_entries.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.test.utils import override_settings + +from taiga.timeline.models import Timeline +from taiga.projects.models import Project + +class Command(BaseCommand): + help = 'Regenerate unnecessary new memberships entry lines' + + @override_settings(DEBUG=False) + def handle(self, *args, **options): + removing_timeline_ids = [] + for t in Timeline.objects.filter(event_type="projects.membership.create").order_by("created"): + print(t.created) + if t.project.owner.id == t.data["user"].get("id", None): + removing_timeline_ids.append(t.id) + + Timeline.objects.filter(id__in=removing_timeline_ids).delete() diff --git a/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py b/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py new file mode 100644 index 000000000..2aef69d0a --- /dev/null +++ b/taiga/timeline/management/commands/_update_timeline_for_updated_tasks.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.management.base import BaseCommand +from django.db.models import Prefetch, F +from django.test.utils import override_settings + +from taiga.timeline.models import Timeline +from taiga.timeline.timeline_implementations import userstory_timeline +from optparse import make_option +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory + + +def update_timeline(initial_date, final_date): + timelines = Timeline.objects.all() + if initial_date: + timelines = timelines.filter(created__gte=initial_date) + if final_date: + timelines = timelines.filter(created__lt=final_date) + + timelines = timelines.filter(event_type="tasks.task.change") + + print("Generating tasks indexed by id dict") + task_ids = timelines.values_list("object_id", flat=True) + + tasks_iterator = Task.objects.filter(id__in=task_ids).select_related("user_story").iterator() + tasks_per_id = {task.id: task for task in tasks_iterator} + del task_ids + + counter = 1 + total = timelines.count() + print("Updating timelines") + for timeline in timelines.iterator(): + print("%s/%s"%(counter, total)) + task_id = timeline.object_id + task = tasks_per_id.get(task_id, None) + if not task: + counter += 1 + continue + + user_story = tasks_per_id[task_id].user_story + if not user_story: + counter += 1 + continue + + timeline.data["task"]["userstory"] = userstory_timeline(user_story) + timeline.save(update_fields=["data"]) + counter += 1 + + +class Command(BaseCommand): + help = 'Regenerate project timeline' + option_list = BaseCommand.option_list + ( + make_option('--initial_date', + action='store', + dest='initial_date', + default=None, + help='Initial date for timeline update'), + ) + ( + make_option('--final_date', + action='store', + dest='final_date', + default=None, + help='Final date for timeline update'), + ) + + @override_settings(DEBUG=False) + def handle(self, *args, **options): + update_timeline(options["initial_date"], options["final_date"]) diff --git a/taiga/timeline/management/commands/rebuild_timeline.py b/taiga/timeline/management/commands/rebuild_timeline.py new file mode 100644 index 000000000..d8929863f --- /dev/null +++ b/taiga/timeline/management/commands/rebuild_timeline.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Examples: +# python manage.py rebuild_timeline --settings=settings.local_timeline --initial_date 2014-10-02 --final_date 2014-10-03 +# python manage.py rebuild_timeline --settings=settings.local_timeline --purge +# python manage.py rebuild_timeline --settings=settings.local_timeline --initial_date 2014-10-02 + +from django.core.management.base import BaseCommand +from django.test.utils import override_settings + +from taiga.timeline.models import Timeline +from taiga.timeline.rebuilder import rebuild_timeline + +from optparse import make_option + + +class Command(BaseCommand): + help = 'Regenerate project timeline' + + def add_arguments(self, parser): + parser.add_argument('--purge', + action='store_true', + dest='purge', + default=False, + help='Purge existing timelines') + parser.add_argument('--initial_date', + action='store', + dest='initial_date', + default=None, + help='Initial date for timeline generation') + parser.add_argument('--final_date', + action='store', + dest='final_date', + default=None, + help='Final date for timeline generation') + parser.add_argument('--project', + action='store', + dest='project', + default=None, + help='Selected project id for timeline generation') + + @override_settings(DEBUG=False) + def handle(self, *args, **options): + if options["purge"] == True: + Timeline.objects.all().delete() + + rebuild_timeline(options["initial_date"], options["final_date"], options["project"]) diff --git a/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py b/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py new file mode 100644 index 000000000..02f0f167d --- /dev/null +++ b/taiga/timeline/management/commands/rebuild_timeline_iterating_per_projects.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.management.base import BaseCommand +from django.test.utils import override_settings +from django.core.management import call_command + +from taiga.projects.models import Project + + +class Command(BaseCommand): + help = 'Regenerate projects timeline iterating per project' + + @override_settings(DEBUG=False) + def handle(self, *args, **options): + total = Project.objects.count() + + for count,project in enumerate(Project.objects.order_by("id")): + print("***********************************\n", + " {}/{} {}\n".format(count+1, total, project.name), + "***********************************") + call_command("rebuild_timeline", project=project.id) diff --git a/taiga/timeline/migrations/0001_initial.py b/taiga/timeline/migrations/0001_initial.py new file mode 100644 index 000000000..d7d488975 --- /dev/null +++ b/taiga/timeline/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.base.db.models.fields +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Timeline', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('object_id', models.PositiveIntegerField()), + ('namespace', models.SlugField(default='default')), + ('event_type', models.SlugField()), + ('data', taiga.base.db.models.fields.JSONField()), + ('created', models.DateTimeField(default=django.utils.timezone.now)), + ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AlterIndexTogether( + name='timeline', + index_together=set([('content_type', 'object_id', 'namespace')]), + ), + ] diff --git a/taiga/timeline/migrations/0002_auto_20150327_1056.py b/taiga/timeline/migrations/0002_auto_20150327_1056.py new file mode 100644 index 000000000..f334954d3 --- /dev/null +++ b/taiga/timeline/migrations/0002_auto_20150327_1056.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + +import taiga.base.db.models.fields +from django.utils import timezone + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0019_auto_20150311_0821'), + ('contenttypes', '0001_initial'), + ('timeline', '0001_initial'), + ('users', '0010_auto_20150414_0936'), + ] + + operations = [ + migrations.DeleteModel( + name='Timeline', + ), + migrations.CreateModel( + name='Timeline', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('object_id', models.PositiveIntegerField()), + ('namespace', models.SlugField(default='default')), + ('event_type', models.SlugField()), + ('project', models.ForeignKey(to='projects.Project', on_delete=models.CASCADE)), + ('data', taiga.base.db.models.fields.JSONField()), + ('data_content_type', models.ForeignKey(to='contenttypes.ContentType', related_name='data_timelines', on_delete=models.CASCADE)), + ('created', models.DateTimeField(default=timezone.now)), + ('content_type', models.ForeignKey(to='contenttypes.ContentType', related_name='content_type_timelines', on_delete=models.CASCADE)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AlterIndexTogether( + name='timeline', + index_together=set([('content_type', 'object_id', 'namespace')]), + ), + ] diff --git a/taiga/timeline/migrations/0003_auto_20150410_0829.py b/taiga/timeline/migrations/0003_auto_20150410_0829.py new file mode 100644 index 000000000..5022e7e29 --- /dev/null +++ b/taiga/timeline/migrations/0003_auto_20150410_0829.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('timeline', '0002_auto_20150327_1056'), + ] + + operations = [ + migrations.AlterField( + model_name='timeline', + name='event_type', + field=models.CharField(db_index=True, max_length=250), + preserve_default=True, + ), + migrations.AlterField( + model_name='timeline', + name='namespace', + field=models.CharField(default='default', max_length=250, db_index=True), + preserve_default=True, + ), + ] diff --git a/taiga/timeline/migrations/0004_auto_20150603_1312.py b/taiga/timeline/migrations/0004_auto_20150603_1312.py new file mode 100644 index 000000000..acc8d38a0 --- /dev/null +++ b/taiga/timeline/migrations/0004_auto_20150603_1312.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('timeline', '0003_auto_20150410_0829'), + ] + + operations = [ + migrations.AlterField( + model_name='timeline', + name='project', + field=models.ForeignKey(null=True, to='projects.Project', on_delete=models.CASCADE), + preserve_default=True, + ), + ] diff --git a/taiga/timeline/migrations/0005_auto_20160706_0723.py b/taiga/timeline/migrations/0005_auto_20160706_0723.py new file mode 100644 index 000000000..adfbd4054 --- /dev/null +++ b/taiga/timeline/migrations/0005_auto_20160706_0723.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-07-06 07:23 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('timeline', '0004_auto_20150603_1312'), + ] + + operations = [ + migrations.AlterField( + model_name='timeline', + name='created', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now), + ), + ] diff --git a/taiga/timeline/migrations/0006_json_to_jsonb.py b/taiga/timeline/migrations/0006_json_to_jsonb.py new file mode 100644 index 000000000..48cbb35cc --- /dev/null +++ b/taiga/timeline/migrations/0006_json_to_jsonb.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.2 on 2016-10-26 11:35 +from __future__ import unicode_literals + +from django.db import migrations +from django.contrib.postgres.fields import JSONField + + +class Migration(migrations.Migration): + + dependencies = [ + ('timeline', '0005_auto_20160706_0723'), + ] + + operations = [ + migrations.RunSQL( + """ + ALTER TABLE "{table_name}" + ALTER COLUMN "{column_name}" + TYPE jsonb + USING regexp_replace("{column_name}"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """.format( + table_name="timeline_timeline", + column_name="data", + ), + reverse_sql=migrations.RunSQL.noop + ), + ] diff --git a/taiga/timeline/migrations/0007_auto_20170406_0615.py b/taiga/timeline/migrations/0007_auto_20170406_0615.py new file mode 100644 index 000000000..aa388ad11 --- /dev/null +++ b/taiga/timeline/migrations/0007_auto_20170406_0615.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.6 on 2017-04-06 06:15 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('timeline', '0006_json_to_jsonb'), + ] + + operations = [ + migrations.AlterIndexTogether( + name='timeline', + index_together=set([('content_type', 'object_id', 'namespace'), ('namespace', 'created')]), + ), + ] diff --git a/taiga/timeline/migrations/0008_auto_20190606_1528.py b/taiga/timeline/migrations/0008_auto_20190606_1528.py new file mode 100644 index 000000000..cb812968c --- /dev/null +++ b/taiga/timeline/migrations/0008_auto_20190606_1528.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.20 on 2019-06-06 15:28 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('timeline', '0007_auto_20170406_0615'), + ] + + operations = [ + migrations.AlterIndexTogether( + name='timeline', + index_together=set([]), + ), + migrations.AddIndex( + model_name='timeline', + index=models.Index(fields=['namespace', '-created'], name='timeline_ti_namespa_89bca1_idx'), + ), + migrations.AddIndex( + model_name='timeline', + index=models.Index(fields=['content_type', 'object_id', '-created'], name='timeline_ti_content_1af26f_idx'), + ), + ] diff --git a/taiga/timeline/migrations/__init__.py b/taiga/timeline/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/timeline/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/timeline/models.py b/taiga/timeline/models.py new file mode 100644 index 000000000..8450786cd --- /dev/null +++ b/taiga/timeline/models.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from taiga.base.db.models.fields import JSONField +from django.utils import timezone + +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey + +from taiga.projects.models import Project + + +class Timeline(models.Model): + content_type = models.ForeignKey(ContentType, related_name="content_type_timelines", on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + namespace = models.CharField(max_length=250, default="default", db_index=True) + event_type = models.CharField(max_length=250, db_index=True) + project = models.ForeignKey(Project, null=True, on_delete=models.CASCADE) + data = JSONField() + data_content_type = models.ForeignKey(ContentType, related_name="data_timelines", on_delete=models.CASCADE) + created = models.DateTimeField(default=timezone.now, db_index=True) + + class Meta: + indexes = [ + models.Index(fields=['namespace', '-created']), + models.Index(fields=['content_type', 'object_id', '-created']), + ] + + +# Register all implementations +from .timeline_implementations import * + +# Register all signals +from .signals import * diff --git a/taiga/timeline/permissions.py b/taiga/timeline/permissions.py new file mode 100644 index 000000000..b568ded35 --- /dev/null +++ b/taiga/timeline/permissions.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission, AllowAny, IsSuperUser +from taiga.permissions.permissions import HasProjectPerm, IsProjectAdmin + + +class UserTimelinePermission(TaigaResourcePermission): + enough_perms = IsSuperUser() + global_perms = None + retrieve_perms = AllowAny() + + +class ProjectTimelinePermission(TaigaResourcePermission): + enough_perms = IsProjectAdmin() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') diff --git a/taiga/timeline/rebuilder.py b/taiga/timeline/rebuilder.py new file mode 100644 index 000000000..7947c4493 --- /dev/null +++ b/taiga/timeline/rebuilder.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Model +from django.test.utils import override_settings + +from taiga.projects.models import Project +from taiga.projects.history.models import HistoryEntry +from .models import Timeline +from .service import _get_impl_key_from_model, _timeline_impl_map, extract_user_info +from .signals import on_new_history_entry, _push_to_timelines + +from unittest.mock import patch + +import gc + + +class BulkCreator(object): + def __init__(self): + self.timeline_objects = [] + self.created = None + + def create_element(self, element): + self.timeline_objects.append(element) + if len(self.timeline_objects) > 999: + self.flush() + + def flush(self): + Timeline.objects.bulk_create(self.timeline_objects, batch_size=1000) + del self.timeline_objects + self.timeline_objects = [] + gc.collect() + +bulk_creator = BulkCreator() + + +def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, + namespace:str="default", extra_data:dict={}): + assert isinstance(obj, Model), "obj must be a instance of Model" + assert isinstance(instance, Model), "instance must be a instance of Model" + event_type_key = _get_impl_key_from_model(instance.__class__, event_type) + impl = _timeline_impl_map.get(event_type_key, None) + + bulk_creator.create_element(Timeline( + content_object=obj, + namespace=namespace, + event_type=event_type_key, + project=instance.project, + data=impl(instance, extra_data=extra_data), + data_content_type=ContentType.objects.get_for_model(instance.__class__), + created=created_datetime, + )) + + +@override_settings(CELERY_ENABLED=False) +def rebuild_timeline(initial_date, final_date, project_id): + if initial_date or final_date or project_id: + timelines = Timeline.objects.all() + if initial_date: + timelines = timelines.filter(created__gte=initial_date) + if final_date: + timelines = timelines.filter(created__lt=final_date) + if project_id: + timelines = timelines.filter(project__id=project_id) + + timelines.delete() + + with patch('taiga.timeline.service._add_to_object_timeline', new=custom_add_to_object_timeline): + # Projects api wasn't a HistoryResourceMixin so we can't interate on the HistoryEntries in this case + projects = Project.objects.order_by("created_date") + history_entries = HistoryEntry.objects.order_by("created_at") + + if initial_date: + projects = projects.filter(created_date__gte=initial_date) + history_entries = history_entries.filter(created_at__gte=initial_date) + + if final_date: + projects = projects.filter(created_date__lt=final_date) + history_entries = history_entries.filter(created_at__lt=final_date) + + if project_id: + project = Project.objects.get(id=project_id) + epic_keys = ['epics.epic:%s'%(id) for id in project.epics.values_list("id", flat=True)] + us_keys = ['userstories.userstory:%s'%(id) for id in project.user_stories.values_list("id", + flat=True)] + tasks_keys = ['tasks.task:%s'%(id) for id in project.tasks.values_list("id", flat=True)] + issue_keys = ['issues.issue:%s'%(id) for id in project.issues.values_list("id", flat=True)] + wiki_keys = ['wiki.wikipage:%s'%(id) for id in project.wiki_pages.values_list("id", flat=True)] + keys = epic_keys + us_keys + tasks_keys + issue_keys + wiki_keys + + projects = projects.filter(id=project_id) + history_entries = history_entries.filter(key__in=keys) + + #Memberships + for membership in project.memberships.exclude(user=None).exclude(user=project.owner): + _push_to_timelines(project, membership.user, membership, "create", membership.created_at, refresh_totals=False) + + for project in projects.iterator(): + print("Project:", project) + extra_data = { + "values_diff": {}, + "user": extract_user_info(project.owner), + } + _push_to_timelines(project, project.owner, project, 'create', + project.created_date, extra_data=extra_data, + refresh_totals=False) + del extra_data + + for historyEntry in history_entries.iterator(): + print("History entry:", historyEntry.created_at) + try: + historyEntry.refresh_totals = False + on_new_history_entry(None, historyEntry, None) + except ObjectDoesNotExist as e: + print("Ignoring") + + for project in projects.iterator(): + project.refresh_totals() + + bulk_creator.flush() diff --git a/taiga/timeline/serializers.py b/taiga/timeline/serializers.py new file mode 100644 index 000000000..85c2db240 --- /dev/null +++ b/taiga/timeline/serializers.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from urllib.parse import urlparse + +from django.core.files.storage import default_storage +from django.conf import settings +from django.contrib.auth import get_user_model + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.base.utils.thumbnails import get_thumbnail_url +from taiga.users.services import get_user_photo_url, get_user_big_photo_url +from taiga.users.gravatar import get_user_gravatar_id + +from . import models + + +class TimelineSerializer(serializers.LightSerializer): + data = serializers.SerializerMethodField("get_data") + id = Field() + content_type = Field(attr="content_type_id") + object_id = Field() + namespace = Field() + event_type = Field() + project = Field(attr="project_id") + data = MethodField() + data_content_type = Field(attr="data_content_type_id") + created = Field() + + class Meta: + model = models.Timeline + + def get_data(self, obj): + # Updates the data user info saved if the user exists + if hasattr(obj, "_prefetched_user"): + user = obj._prefetched_user + else: + User = get_user_model() + userData = obj.data.get("user", None) + try: + user = User.objects.get(id=userData["id"]) + except User.DoesNotExist: + user = None + + if user is not None: + obj.data["user"] = { + "id": user.pk, + "name": user.get_full_name(), + "photo": get_user_photo_url(user), + "big_photo": get_user_big_photo_url(user), + "gravatar_id": get_user_gravatar_id(user), + "username": user.username, + "is_profile_visible": user.is_active and not user.is_system, + "date_joined": user.date_joined + } + + if "values_diff" in obj.data and "attachments" in obj.data["values_diff"]: + [[self.parse_url(item) for item in value] for key, value in + obj.data["values_diff"]["attachments"].items() if value] + + return obj.data + + def parse_url(self, item): + if 'attached_file' in item: + attached_file = item['attached_file'] + else: + # This is the case for old timeline entries + file_path = urlparse(item['url']).path + index = file_path.find('/attachments') + attached_file = file_path[index+1:] + + item['url'] = default_storage.url(attached_file) + + if 'thumbnail_file' in item: + thumb_file = item['thumbnail_file'] + thumb_url = default_storage.url(thumb_file) if thumb_file else None + else: + thumb_url = get_thumbnail_url(attached_file, + settings.THN_ATTACHMENT_TIMELINE) + + item['thumb_url'] = thumb_url diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py new file mode 100644 index 000000000..836cbeb07 --- /dev/null +++ b/taiga/timeline/service.py @@ -0,0 +1,349 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.db.models import Model +from django.db.models.expressions import RawSQL +from django.db.models import Q +from django.db.models.query import QuerySet +from django.db import connection + +from functools import partial, wraps + +from taiga.base.utils.db import get_typename_for_model_class +from taiga.celery import app + +_timeline_impl_map = {} + + +def _get_impl_key_from_model(model: Model, event_type: str): + if issubclass(model, Model): + typename = get_typename_for_model_class(model) + return _get_impl_key_from_typename(typename, event_type) + raise Exception("Not valid model parameter") + + +def _get_impl_key_from_typename(typename: str, event_type: str): + if isinstance(typename, str): + return "{0}.{1}".format(typename, event_type) + raise Exception("Not valid typename parameter") + + +def build_user_namespace(user: object): + return "{0}:{1}".format("user", user.id) + + +def build_project_namespace(project: object): + return "{0}:{1}".format("project", project.id) + + +def _add_to_object_timeline(obj: object, instance: object, event_type: str, created_datetime: object, + namespace: str="default", extra_data: dict={}): + assert isinstance(obj, Model), "obj must be a instance of Model" + assert isinstance(instance, Model), "instance must be a instance of Model" + from .models import Timeline + event_type_key = _get_impl_key_from_model(instance.__class__, event_type) + impl = _timeline_impl_map.get(event_type_key, None) + + project = None + if hasattr(instance, "project"): + project = instance.project + + Timeline.objects.create( + content_object=obj, + namespace=namespace, + event_type=event_type_key, + project=project, + data=impl(instance, extra_data=extra_data), + data_content_type=ContentType.objects.get_for_model(instance.__class__), + created=created_datetime, + ) + + +def _add_to_objects_timeline(objects, instance: object, event_type: str, created_datetime: object, + namespace: str="default", extra_data: dict={}): + for obj in objects: + _add_to_object_timeline(obj, instance, event_type, created_datetime, namespace, extra_data) + + +def _push_to_timeline(objects, instance: object, event_type: str, created_datetime: object, + namespace: str="default", extra_data: dict={}): + if isinstance(objects, Model): + _add_to_object_timeline(objects, instance, event_type, created_datetime, namespace, extra_data) + elif isinstance(objects, QuerySet) or isinstance(objects, list): + _add_to_objects_timeline(objects, instance, event_type, created_datetime, namespace, extra_data) + else: + raise Exception("Invalid objects parameter") + + +@app.task +def push_to_timelines(project_id, user_id, obj_app_label, obj_model_name, obj_id, event_type, + created_datetime, extra_data={}, refresh_totals=True): + + ObjModel = apps.get_model(obj_app_label, obj_model_name) + try: + obj = ObjModel.objects.get(id=obj_id) + except ObjModel.DoesNotExist: + return + + try: + user = get_user_model().objects.get(id=user_id) + except get_user_model().DoesNotExist: + return + + if project_id is not None: + # Actions related with a project + + projectModel = apps.get_model("projects", "Project") + try: + project = projectModel.objects.get(id=project_id) + except projectModel.DoesNotExist: + return + + # Project timeline + _push_to_timeline(project, obj, event_type, created_datetime, + namespace=build_project_namespace(project), + extra_data=extra_data) + + if refresh_totals: + project.refresh_totals() + + if hasattr(obj, "get_related_people"): + related_people = obj.get_related_people() + + _push_to_timeline(related_people, obj, event_type, created_datetime, + namespace=build_user_namespace(user), + extra_data=extra_data) + else: + # Actions not related with a project + # - Me + _push_to_timeline(user, obj, event_type, created_datetime, + namespace=build_user_namespace(user), + extra_data=extra_data) + + +def get_timeline(obj, namespace=None): + assert isinstance(obj, Model), "obj must be a instance of Model" + from .models import Timeline + + ct = ContentType.objects.get_for_model(obj.__class__) + timeline = Timeline.objects.filter(content_type=ct) + + if namespace is not None: + timeline = timeline.filter(namespace=namespace) + else: + timeline = timeline.filter(object_id=obj.pk) + + timeline = timeline.order_by("-created") + return timeline + + +def filter_timeline_for_user(timeline, user, namespace=None): + # Superusers can see everything + if user.is_superuser: + return timeline + + # Filtering entities from public projects or entities without project + tl_filter = Q(project__is_private=False) | Q(project=None) + + # Filtering private project with some public parts + content_types = { + "view_project": ContentType.objects.get_by_natural_key("projects", "project"), + "view_milestones": ContentType.objects.get_by_natural_key("milestones", "milestone"), + "view_epics": ContentType.objects.get_by_natural_key("epics", "epic"), + "view_us": ContentType.objects.get_by_natural_key("userstories", "userstory"), + "view_tasks": ContentType.objects.get_by_natural_key("tasks", "task"), + "view_issues": ContentType.objects.get_by_natural_key("issues", "issue"), + "view_wiki_pages": ContentType.objects.get_by_natural_key("wiki", "wikipage"), + "view_wiki_links": ContentType.objects.get_by_natural_key("wiki", "wikilink"), + } + + for content_type_key, content_type in content_types.items(): + tl_filter |= Q(project__is_private=True, + project__anon_permissions__contains=[content_type_key], + data_content_type=content_type) + + # There is no specific permission for seeing new memberships + membership_content_type = ContentType.objects.get_by_natural_key(app_label="projects", model="membership") + tl_filter |= Q(project__is_private=True, + project__anon_permissions__contains=["view_project"], + data_content_type=membership_content_type) + + # Filtering private projects where user is member + if not user.is_anonymous: + for membership in user.cached_memberships: + # Admin roles can see everything in a project + if membership.is_admin: + tl_filter |= Q(project=membership.project) + else: + data_content_types = list(filter(None, [content_types.get(a, None) for a in + membership.role.permissions])) + data_content_types.append(membership_content_type) + tl_filter |= Q(project=membership.project, data_content_type__in=data_content_types) + + timeline = timeline.filter(tl_filter) + + if namespace: + timeline = timeline.exclude(id__in=_get_not_allowed_epic_related_query(user, namespace)) + + return timeline + + +def _get_not_allowed_epic_related_query(accessing_user, namespace): + sql = """ + select tt.id + from timeline_timeline tt + inner join projects_project pp + -- project of the epic's related user story + on cast (data -> 'userstory' -> 'project' ->> 'id' as INTEGER) = pp.id + where + not ( + -- Allowed for anonymous users + 'view_us' = ANY(pp.anon_permissions) + or + -- Allowed for registered users + ('view_us' = ANY(pp.public_permissions) and {user_id} <> -1) + or + -- Allowed for a project member with a privileged role + exists (select * from users_role ur + inner join projects_membership pm + ON ur.id = pm.role_id + where pm.user_id = {user_id} + and pm.project_id = pp.id + and 'view_us' = ANY(ur.permissions)) + ) + and tt.namespace = '{namespace}' + and tt.event_type = 'epics.relateduserstory.create' + """ + accessing_user_id = accessing_user.id or -1 # -1 just in case of anonymous user + sql = sql.format(user_id=accessing_user_id, namespace=namespace) + + return RawSQL(sql, ()) + + +def get_profile_timeline(user, accessing_user=None): + timeline = get_timeline(user) + if accessing_user is not None: + timeline = filter_timeline_for_user(timeline, accessing_user) + return timeline + + +def get_user_timeline(user, accessing_user=None): + namespace = build_user_namespace(user) + timeline = get_timeline(user, namespace) + if accessing_user is not None: + timeline = filter_timeline_for_user(timeline, accessing_user, namespace) + return timeline + + +def get_project_timeline(project, accessing_user=None): + namespace = build_project_namespace(project) + timeline = get_timeline(project, namespace) + if accessing_user is not None: + timeline = filter_timeline_for_user(timeline, accessing_user, namespace) + + return timeline + + +def register_timeline_implementation(typename: str, event_type: str, fn=None): + assert isinstance(typename, str), "typename must be a string" + assert isinstance(event_type, str), "event_type must be a string" + + if fn is None: + return partial(register_timeline_implementation, typename, event_type) + + @wraps(fn) + def _wrapper(*args, **kwargs): + return fn(*args, **kwargs) + + key = _get_impl_key_from_typename(typename, event_type) + + _timeline_impl_map[key] = _wrapper + return _wrapper + + +def extract_project_info(instance): + return { + "id": instance.pk, + "slug": instance.slug, + "name": instance.name, + "description": instance.description, + } + + +def extract_user_info(instance): + return { + "id": instance.pk + } + + +def extract_milestone_info(instance): + return { + "id": instance.pk, + "slug": instance.slug, + "name": instance.name, + } + + +def extract_epic_info(instance): + return { + "id": instance.pk, + "ref": instance.ref, + "subject": instance.subject, + } + + +def extract_userstory_info(instance, include_project=False): + userstory_info = { + "id": instance.pk, + "ref": instance.ref, + "subject": instance.subject, + } + + if include_project: + userstory_info["project"] = extract_project_info(instance.project) + + return userstory_info + + +def extract_related_userstory_info(instance): + return { + "id": instance.pk, + "subject": instance.user_story.subject + } + + +def extract_issue_info(instance): + return { + "id": instance.pk, + "ref": instance.ref, + "subject": instance.subject, + } + + +def extract_task_info(instance): + return { + "id": instance.pk, + "ref": instance.ref, + "subject": instance.subject, + } + + +def extract_wiki_page_info(instance): + return { + "id": instance.pk, + "slug": instance.slug, + } + + +def extract_role_info(instance): + return { + "id": instance.pk, + "name": instance.name, + } diff --git a/taiga/timeline/signals.py b/taiga/timeline/signals.py new file mode 100644 index 000000000..59f41f93e --- /dev/null +++ b/taiga/timeline/signals.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone +from django.utils.translation import gettext as _ +from django.db import connection + +from taiga.projects.history import services as history_services +from taiga.projects.history.choices import HistoryType +from taiga.timeline.service import (push_to_timelines, + build_user_namespace, + build_project_namespace, + extract_user_info) + + +def _push_to_timelines(project, user, obj, event_type, created_datetime, extra_data={}, refresh_totals=True): + project_id = None if project is None else project.id + + ct = ContentType.objects.get_for_model(obj) + if settings.CELERY_ENABLED: + connection.on_commit(lambda: push_to_timelines.delay(project_id, + user.id, + ct.app_label, + ct.model, + obj.id, + event_type, + created_datetime, + extra_data=extra_data, + refresh_totals=refresh_totals)) + else: + push_to_timelines(project_id, + user.id, + ct.app_label, + ct.model, + obj.id, + event_type, + created_datetime, + extra_data=extra_data, + refresh_totals=refresh_totals) + + +def _clean_description_fields(values_diff): + # Description_diff and description_html if included can be huge, we are + # removing the html one and clearing the diff + values_diff.pop("description_html", None) + if "description_diff" in values_diff: + values_diff["description_diff"] = _("Check the history API for the exact diff") + + +def on_new_history_entry(sender, instance, created, **kwargs): + if instance._importing: + return + + if instance.is_hidden: + return None + + if instance.user["pk"] is None: + return None + + refresh_totals = getattr(instance, "refresh_totals", True) + + model = history_services.get_model_from_key(instance.key) + pk = history_services.get_pk_from_key(instance.key) + obj = model.objects.get(pk=pk) + project = obj.project + + if instance.type == HistoryType.create: + event_type = "create" + elif instance.type == HistoryType.change: + event_type = "change" + elif instance.type == HistoryType.delete: + event_type = "delete" + + user = get_user_model().objects.get(id=instance.user["pk"]) + values_diff = instance.values_diff + _clean_description_fields(values_diff) + + extra_data = { + "values_diff": values_diff, + "user": extract_user_info(user), + "comment": instance.comment, + "comment_html": instance.comment_html, + } + + # Detect deleted comment + if instance.delete_comment_date: + extra_data["comment_deleted"] = True + + # Detect edited comment + if instance.comment_versions is not None and len(instance.comment_versions)>0: + extra_data["comment_edited"] = True + + created_datetime = instance.created_at + _push_to_timelines(project, user, obj, event_type, created_datetime, extra_data=extra_data, refresh_totals=refresh_totals) + + +def create_membership_push_to_timeline(sender, instance, created, **kwargs): + """ + Creating new membership with associated user. If the user is the project owner we don't + do anything because that info will be shown in created project timeline entry + + @param sender: Membership model + @param instance: Membership object + """ + + # We shown in created project timeline entry + if created and instance.user and instance.user != instance.project.owner: + created_datetime = instance.created_at + _push_to_timelines(instance.project, instance.user, instance, "create", created_datetime) + + +def delete_membership_push_to_timeline(sender, instance, **kwargs): + if instance.user: + created_datetime = timezone.now() + _push_to_timelines(instance.project, instance.user, instance, "delete", created_datetime) + + +def create_user_push_to_timeline(sender, instance, created, **kwargs): + if created: + project = None + user = instance + _push_to_timelines(project, user, user, "create", created_datetime=user.date_joined) diff --git a/taiga/timeline/timeline_implementations.py b/taiga/timeline/timeline_implementations.py new file mode 100644 index 000000000..49a9eed54 --- /dev/null +++ b/taiga/timeline/timeline_implementations.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.timeline.service import register_timeline_implementation +from . import service + + +@register_timeline_implementation("projects.project", "create") +@register_timeline_implementation("projects.project", "change") +@register_timeline_implementation("projects.project", "delete") +def project_timeline(instance, extra_data={}): + result = { + "project": service.extract_project_info(instance), + } + result.update(extra_data) + return result + + +@register_timeline_implementation("milestones.milestone", "create") +@register_timeline_implementation("milestones.milestone", "change") +@register_timeline_implementation("milestones.milestone", "delete") +def milestone_timeline(instance, extra_data={}): + result = { + "milestone": service.extract_milestone_info(instance), + "project": service.extract_project_info(instance.project), + } + result.update(extra_data) + return result + + +@register_timeline_implementation("epics.epic", "create") +@register_timeline_implementation("epics.epic", "change") +@register_timeline_implementation("epics.epic", "delete") +def epic_timeline(instance, extra_data={}): + result = { + "epic": service.extract_epic_info(instance), + "project": service.extract_project_info(instance.project), + } + result.update(extra_data) + return result + + +@register_timeline_implementation("epics.relateduserstory", "create") +@register_timeline_implementation("epics.relateduserstory", "change") +@register_timeline_implementation("epics.relateduserstory", "delete") +def epic_related_userstory_timeline(instance, extra_data={}): + result = { + "relateduserstory": service.extract_related_userstory_info(instance), + "epic": service.extract_epic_info(instance.epic), + "userstory": service.extract_userstory_info(instance.user_story, include_project=True), + "project": service.extract_project_info(instance.project), + } + result.update(extra_data) + return result + + +@register_timeline_implementation("userstories.userstory", "create") +@register_timeline_implementation("userstories.userstory", "change") +@register_timeline_implementation("userstories.userstory", "delete") +def userstory_timeline(instance, extra_data={}): + result = { + "userstory": service.extract_userstory_info(instance), + "project": service.extract_project_info(instance.project), + } + + if instance.milestone is not None: + result["milestone"] = service.extract_milestone_info(instance.milestone) + + result.update(extra_data) + return result + + +@register_timeline_implementation("issues.issue", "create") +@register_timeline_implementation("issues.issue", "change") +@register_timeline_implementation("issues.issue", "delete") +def issue_timeline(instance, extra_data={}): + result = { + "issue": service.extract_issue_info(instance), + "project": service.extract_project_info(instance.project), + } + result.update(extra_data) + return result + + +@register_timeline_implementation("tasks.task", "create") +@register_timeline_implementation("tasks.task", "change") +@register_timeline_implementation("tasks.task", "delete") +def task_timeline(instance, extra_data={}): + result = { + "task": service.extract_task_info(instance), + "project": service.extract_project_info(instance.project), + } + + if instance.user_story: + result["task"]["userstory"] = service.extract_userstory_info(instance.user_story) + + result.update(extra_data) + return result + + +@register_timeline_implementation("wiki.wikipage", "create") +@register_timeline_implementation("wiki.wikipage", "change") +@register_timeline_implementation("wiki.wikipage", "delete") +def wiki_page_timeline(instance, extra_data={}): + result = { + "wikipage": service.extract_wiki_page_info(instance), + "project": service.extract_project_info(instance.project), + } + result.update(extra_data) + return result + + +@register_timeline_implementation("projects.membership", "create") +@register_timeline_implementation("projects.membership", "delete") +def membership_timeline(instance, extra_data={}): + result = { + "user": service.extract_user_info(instance.user), + "project": service.extract_project_info(instance.project), + "role": service.extract_role_info(instance.role), + } + result.update(extra_data) + return result + + +@register_timeline_implementation("users.user", "create") +def user_timeline(instance, extra_data={}): + result = { + "user": service.extract_user_info(instance), + } + result.update(extra_data) + return result diff --git a/taiga/urls.py b/taiga/urls.py new file mode 100644 index 000000000..972fb3b16 --- /dev/null +++ b/taiga/urls.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings +from django.urls import include, re_path +from django.contrib import admin +from django.urls import path + +from .routers import router + + +############################################## +# Default +############################################## + +urlpatterns = [ + path('api/v1/', include(router.urls)), + path('admin/', admin.site.urls), +] + +handler500 = "taiga.base.api.views.api_server_error" + + +############################################## +# Front sitemap +############################################## + +if settings.FRONT_SITEMAP_ENABLED: + from django.contrib.sitemaps.views import index + from django.contrib.sitemaps.views import sitemap + from django.views.decorators.cache import cache_page + + from taiga.front.sitemaps import sitemaps + + urlpatterns += [ + re_path(r"^front/sitemap\.xml$", + cache_page(settings.FRONT_SITEMAP_CACHE_TIMEOUT)(index), + {"sitemaps": sitemaps, 'sitemap_url_name': 'front-sitemap'}, + name="front-sitemap-index"), + re_path(r"^front/sitemap-(?P
.+)\.xml$", + cache_page(settings.FRONT_SITEMAP_CACHE_TIMEOUT)(sitemap), + {"sitemaps": sitemaps}, + name="front-sitemap") + ] + + +############################################## +# Static and media files in debug mode +############################################## + +if settings.DEBUG: + from django.contrib.staticfiles.urls import staticfiles_urlpatterns + + def mediafiles_urlpatterns(prefix): + """ + Method for serve media files with runserver. + """ + import re + from django.views.static import serve + + return [ + re_path(r'^%s(?P.*)$' % re.escape(prefix.lstrip('/')), serve, + {'document_root': settings.MEDIA_ROOT}) + ] + + # Hardcoded only for development server + urlpatterns += staticfiles_urlpatterns(prefix="/static/") + urlpatterns += mediafiles_urlpatterns(prefix="/media/") diff --git a/taiga/users/__init__.py b/taiga/users/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/users/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/users/admin.py b/taiga/users/admin.py new file mode 100644 index 000000000..a5da07963 --- /dev/null +++ b/taiga/users/admin.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps + +from django.contrib import admin +from django.contrib.auth.models import Group, Permission +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin + +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from .models import Role, User +from .forms import UserChangeForm, UserCreationForm + + +SEPARATOR = "   |   " + +admin.site.unregister(Group) + + +## Inlines + +class MembershipsInline(admin.TabularInline): + model = apps.get_model("projects", "Membership") + fk_name = "user" + verbose_name = _("Project Member") + verbose_name_plural = _("Project Members") + fields = ("project_id", "project_name", "project_slug", "project_is_private", + "project_owner", "is_admin") + readonly_fields = ("project_id", "project_name", "project_slug", "project_is_private", + "project_owner", "is_admin") + show_change_link = True + extra = 0 + + @admin.display( + description=_("id") + ) + def project_id(self, obj): + return obj.project.id if obj.project else None + + @admin.display( + description=_("name") + ) + def project_name(self, obj): + return obj.project.name if obj.project else None + + @admin.display( + description=_("slug") + ) + def project_slug(self, obj): + return obj.project.slug if obj.project else None + + @admin.display( + description=_("is private"), + boolean=True, + ) + def project_is_private(self, obj): + return obj.project.is_private if obj.project else None + + @admin.display( + description=_("owner") + ) + def project_owner(self, obj): + if obj.project and obj.project.owner: + return "{} (@{})".format(obj.project.owner.get_full_name(), obj.project.owner.username) + return None + + def has_add_permission(self, *args): + return False + + def has_delete_permission(self, *args): + return False + + +class OwnedProjectsInline(admin.TabularInline): + model = apps.get_model("projects", "Project") + fk_name = "owner" + verbose_name = _("Project Ownership") + verbose_name_plural = _("Project Ownerships") + fields = ("id", "name", "slug", "is_private", "total_memberships") + readonly_fields = ("id", "name", "slug", "is_private", "total_memberships") + show_change_link = True + extra = 0 + + def has_add_permission(self, *args): + return False + + def has_delete_permission(self, *args): + return False + + @admin.display( + description=_("Memberships") + ) + def total_memberships(self, obj): + total = obj.memberships.all().count() + pending = obj.memberships.filter(user=None).count() + accepted = total - pending + return mark_safe(f"{total}{SEPARATOR}{accepted} accepted{SEPARATOR}{pending} pending") + + + +class RoleInline(admin.TabularInline): + model = Role + extra = 0 + + +## Admin panels + +class RoleAdmin(admin.ModelAdmin): + list_display = ["name"] + filter_horizontal = ("permissions",) + + def formfield_for_manytomany(self, db_field, request=None, **kwargs): + if db_field.name == "permissions": + qs = kwargs.get("queryset", db_field.rel.to.objects) + # Avoid a major performance hit resolving permission names which + # triggers a content_type load: + kwargs["queryset"] = qs.select_related("content_type") + return super().formfield_for_manytomany( + db_field, request=request, **kwargs) + + +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + list_display = ("username", "email", "full_name") + list_filter = ("is_superuser", "is_active", "verified_email") + search_fields = ("username", "full_name", "email") + ordering = ("username",) + readonly_fields = ( + "total_private_projects", "total_memberships_private_projects", + "total_public_projects", "total_memberships_public_projects", + ) + filter_horizontal = () + fieldsets = ( + (None, {"fields": ("username", "password")}), + (_("PERSONAL INFO"), {"fields": ("full_name", "email", "bio", "photo")}), + (_("EXTRA INFO"), {"fields": ("color", "lang", "timezone", "token", "colorize_tags", + "email_token", "new_email", "verified_email", "accepted_terms", "read_new_terms")}), + (_("PERMISSIONS"), {"fields": ("is_active", "is_superuser")}), + (_("IMPORTANT DATES"), {"fields": (("last_login", "date_joined", "date_cancelled"),)}), + (_("PROJECT OWNERSHIPS RESTRICTIONS"), {"fields": (("max_private_projects", "max_memberships_private_projects"), + ("max_public_projects", "max_memberships_public_projects"))}), + (_("PROJECT OWNERSHIPS STATS"), {"fields": (("total_private_projects", + "total_memberships_private_projects"), + ("total_public_projects", + "total_memberships_public_projects"))}), + ) + # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin + # overrides get_fieldsets to use this attribute when creating a user. + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'email', 'password1', 'password2')} + ), + ) + inlines = [ + OwnedProjectsInline, + MembershipsInline + ] + form = UserChangeForm + add_form = UserCreationForm + + @admin.display( + description=_("Private projects owned") + ) + def total_private_projects(self, obj): + return obj.owned_projects.filter(is_private=True).count() + + @admin.display( + description=_("Private memberships owned") + ) + def total_memberships_private_projects(self, obj): + Membership = apps.get_model("projects", "Membership") + accepted = (Membership.objects.filter(project__is_private=True, + project__owner_id=obj.id, + user_id__isnull=False) + .values("user_id") + .distinct().count()) + pending = (Membership.objects.filter(project__is_private=True, + project__owner_id=obj.id, + user_id__isnull=True) + .values("email") + .distinct().count()) + total = pending + accepted + return mark_safe(f"{total}{SEPARATOR}{accepted} accepted{SEPARATOR}{pending} pending") + + @admin.display( + description=_("Public projects owned") + ) + def total_public_projects(self, obj): + return obj.owned_projects.filter(is_private=False).count() + + @admin.display( + description=_("Public memberships owned") + ) + def total_memberships_public_projects(self, obj): + Membership = apps.get_model("projects", "Membership") + accepted = (Membership.objects.filter(project__is_private=False, + project__owner_id=obj.id, + user_id__isnull=False) + .values("user_id") + .distinct().count()) + pending = (Membership.objects.filter(project__is_private=False, + project__owner_id=obj.id, + user_id__isnull=True) + .values("email") + .distinct().count()) + total = pending + accepted + return mark_safe(f"{total}{SEPARATOR}{accepted} accepted{SEPARATOR}{pending} pending") + + diff --git a/taiga/users/api.py b/taiga/users/api.py new file mode 100644 index 000000000..c810cbfe6 --- /dev/null +++ b/taiga/users/api.py @@ -0,0 +1,474 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.apps import apps +from django.utils.translation import gettext as _ +from django.core.validators import validate_email +from django.core.exceptions import ValidationError +from django.conf import settings + +from taiga.auth.exceptions import TokenError +from taiga.auth.tokens import CancelToken +from taiga.auth.settings import api_settings as auth_settings +from taiga.base import exceptions as exc +from taiga.base import filters +from taiga.base import response +from taiga.base.utils.dicts import into_namedtuple +from taiga.base.decorators import list_route +from taiga.base.decorators import detail_route +from taiga.base.api.fields import validate_user_email_allowed_domains +from taiga.base.api.mixins import BlockedByProjectMixin +from taiga.base.api.viewsets import ModelCrudViewSet +from taiga.base.api.utils import get_object_or_404 +from taiga.base.filters import MembersFilterBackend +from taiga.base.mails import mail_builder +from taiga.users.services import get_user_by_username_or_email +from easy_thumbnails.source_generators import pil_image + +from . import models +from . import serializers +from . import validators +from . import permissions +from . import filters as user_filters +from . import services +from . import utils as user_utils +from .signals import user_cancel_account as user_cancel_account_signal +from .signals import user_change_email as user_change_email_signal +from .throttling import UserDetailRateThrottle, UserUpdateRateThrottle + +class UsersViewSet(ModelCrudViewSet): + permission_classes = (permissions.UserPermission,) + admin_serializer_class = serializers.UserAdminSerializer + serializer_class = serializers.UserSerializer + admin_validator_class = validators.UserAdminValidator + validator_class = validators.UserValidator + filter_backends = (MembersFilterBackend,) + throttle_classes = (UserDetailRateThrottle, UserUpdateRateThrottle) + model = models.User + + def get_serializer_class(self): + if self.action in ["partial_update", "update", "retrieve", "by_username"]: + user = self.object + if self.request.user == user or self.request.user.is_superuser: + return self.admin_serializer_class + + return self.serializer_class + + def get_validator_class(self): + if self.action in ["partial_update", "update", "retrieve", "by_username"]: + user = self.object + if self.request.user == user or self.request.user.is_superuser: + return self.admin_validator_class + + return self.validator_class + + def get_queryset(self): + qs = super().get_queryset() + qs = qs.prefetch_related("memberships") + qs = user_utils.attach_extra_info(qs, user=self.request.user) + return qs + + def create(self, *args, **kwargs): + raise exc.NotSupported() + + def list(self, request, *args, **kwargs): + self.object_list = MembersFilterBackend().filter_queryset(request, + self.get_queryset(), + self) + + page = self.paginate_queryset(self.object_list) + if page is not None: + serializer = self.get_pagination_serializer(page) + else: + serializer = self.get_serializer(self.object_list, many=True) + + return response.Ok(serializer.data) + + def retrieve(self, request, *args, **kwargs): + self.object = get_object_or_404(self.get_queryset(), **kwargs) + self.check_permissions(request, 'retrieve', self.object) + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + # TODO: commit_on_success + def partial_update(self, request, *args, **kwargs): + """ + We must detect if the user is trying to change his email so we can + save that value and generate a token that allows him to validate it in + the new email account + """ + user = self.get_object() + self.check_permissions(request, "update", user) + + new_email = request.DATA.pop('email', None) + if new_email is not None: + valid_new_email = True + duplicated_email = models.User.objects.filter(email=new_email).exists() + + try: + validate_email(new_email) + validate_user_email_allowed_domains(new_email) + except ValidationError: + valid_new_email = False + + valid_new_email = valid_new_email and new_email != request.user.email + + if duplicated_email: + raise exc.WrongArguments(_("Duplicated email")) + elif not valid_new_email: + raise exc.WrongArguments(_("Invalid email")) + + # We need to generate a token for the email + request.user.email_token = str(uuid.uuid4()) + request.user.new_email = new_email + request.user.save(update_fields=["email_token", "new_email"]) + email = mail_builder.change_email( + request.user.new_email, + { + "user": request.user, + "lang": request.user.lang + } + ) + email.send() + + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, pk=None): + user = self.get_object() + self.check_permissions(request, "destroy", user) + stream = request.stream + request_data = stream is not None and stream.GET or None + user_cancel_account_signal.send(sender=user.__class__, user=user, request_data=request_data) + user.cancel() + return response.NoContent() + + @list_route(methods=["GET"]) + def by_username(self, request, *args, **kwargs): + username = request.QUERY_PARAMS.get("username", None) + return self.retrieve(request, username=username) + + @list_route(methods=["POST"]) + def password_recovery(self, request, pk=None): + username_or_email = request.DATA.get('username', None) + + self.check_permissions(request, "password_recovery", None) + + if not username_or_email: + raise exc.WrongArguments(_("Invalid username or email")) + + user = get_user_by_username_or_email(username_or_email) + user.token = str(uuid.uuid4()) + user.save(update_fields=["token"]) + + email = mail_builder.password_recovery(user, {"user": user}) + email.send() + + return response.Ok({"detail": _("Mail sent successfully!")}) + + @list_route(methods=["POST"]) + def change_password_from_recovery(self, request, pk=None): + """ + Change password with token (from password recovery step). + """ + + self.check_permissions(request, "change_password_from_recovery", None) + + validator = validators.RecoveryValidator(data=request.DATA, many=False) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + try: + user = models.User.objects.get(token=validator.data["token"]) + except models.User.DoesNotExist: + raise exc.WrongArguments(_("Token is invalid")) + + user.set_password(validator.data["password"]) + user.token = None + user.save(update_fields=["password", "token"]) + + return response.NoContent() + + @list_route(methods=["POST"]) + def change_password(self, request, pk=None): + """ + Change password to current logged user. + """ + self.check_permissions(request, "change_password", None) + + current_password = request.DATA.get("current_password") + password = request.DATA.get("password") + + # NOTE: GitHub users have no password yet (request.user.password == '') so + # current_password can be None + if not current_password and request.user.password: + raise exc.WrongArguments(_("Current password parameter needed")) + + if not password: + raise exc.WrongArguments(_("New password parameter needed")) + + if len(password) < 6: + raise exc.WrongArguments(_("Invalid password length at least 6 characters needed")) + + if current_password and not request.user.check_password(current_password): + raise exc.WrongArguments(_("Invalid current password")) + + request.user.set_password(password) + request.user.save(update_fields=["password"]) + return response.NoContent() + + @list_route(methods=["POST"]) + def change_avatar(self, request): + """ + Change avatar to current logged user. + """ + self.check_permissions(request, "change_avatar", None) + + avatar = request.FILES.get('avatar', None) + + if not avatar: + raise exc.WrongArguments(_("Incomplete arguments")) + + try: + pil_image(avatar) + except Exception: + raise exc.WrongArguments(_("Invalid image format")) + + request.user.photo = avatar + request.user.save(update_fields=["photo"]) + user_data = self.admin_serializer_class(request.user).data + + return response.Ok(user_data) + + @list_route(methods=["POST"]) + def remove_avatar(self, request): + """ + Remove the avatar of current logged user. + """ + self.check_permissions(request, "remove_avatar", None) + request.user.photo = None + request.user.save(update_fields=["photo"]) + user_data = self.admin_serializer_class(request.user).data + return response.Ok(user_data) + + @list_route(methods=["POST"]) + def change_email(self, request, pk=None): + """ + Verify the email change to current logged user. + """ + validator = validators.ChangeEmailValidator(data=request.DATA, many=False) + if not validator.is_valid(): + return response.BadRequest(validator.errors) + + try: + user = models.User.objects.get(email_token=validator.data["email_token"]) + except models.User.DoesNotExist: + raise exc.WrongArguments(_("Invalid, are you sure the token is correct and you " + "didn't use it before?")) + + self.check_permissions(request, "change_email", user) + + old_email = user.email + new_email = user.new_email + + user.email = new_email + user.new_email = None + user.email_token = None + user.verified_email = True + user.save(update_fields=["email", "new_email", "email_token", "verified_email"]) + + user_change_email_signal.send(sender=user.__class__, + user=user, + old_email=old_email, + new_email=new_email) + + # If the user changes their email, the application will ask again if they + # want to subscribe to the Taiga newsletter. + user.storage_entries.filter(key="dont_ask_premise_newsletter").delete() + + return response.NoContent() + + @list_route(methods=["GET"]) + def me(self, request, pk=None): + """ + Get me. + """ + self.check_permissions(request, "me", None) + user_data = self.admin_serializer_class(request.user).data + return response.Ok(user_data) + + @list_route(methods=["POST"]) + def cancel(self, request, pk=None): + """ + Cancel an account via token + """ + validator = validators.CancelAccountValidator(data=request.DATA, many=False) + if not validator.is_valid(): + raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) + + try: + validated_token = CancelToken(token=validator.data["cancel_token"]) + user_id_value = validated_token[auth_settings.USER_ID_CLAIM] + user = models.User.objects.get(**{auth_settings.USER_ID_FIELD: user_id_value}) + except (exc.NotAuthenticated, models.User.DoesNotExist, TokenError, KeyError): + raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) + + if not user.is_active: + raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) + + user.cancel() + return response.NoContent() + + @list_route(methods=["POST"]) + def export(self, request, pk=None): + """ + Export user data and photo + """ + file_url = services.export_profile(request.user) + + response_data = { + "url": file_url + } + return response.Ok(response_data) + + @list_route(methods=["POST"]) + def send_verification_email(self, request, pk=None): + """Send email to verify the user email address.""" + self.check_permissions(request, "send_verification_email", None) + if request.user.verified_email is True: + raise exc.BadRequest(_("Email address already verified")) + if not request.user.email_token or request.user.email != request.user.new_email: + raise exc.BadRequest(_("Unable to verify this email address")) + email = mail_builder.send_verification(request.user, {"user": request.user}) + email.send() + return response.Ok({"detail": _("Mail sended successful!")}) + + @detail_route(methods=["GET"]) + def contacts(self, request, *args, **kwargs): + user = get_object_or_404(models.User, **kwargs) + self.check_permissions(request, 'contacts', user) + + self.object_list = user_filters.ContactsFilterBackend().filter_queryset( + user, request, self.get_queryset(), self).extra( + select={"complete_user_name": "concat(full_name, username)"}).order_by("complete_user_name") + + page = self.paginate_queryset(self.object_list) + if page is not None: + serializer = self.serializer_class(page.object_list, many=True) + else: + serializer = self.serializer_class(self.object_list, many=True) + + return response.Ok(serializer.data) + + @detail_route(methods=["GET"]) + def stats(self, request, *args, **kwargs): + user = get_object_or_404(models.User, **kwargs) + self.check_permissions(request, "stats", user) + return response.Ok(services.get_stats_for_user(user, request.user)) + + @detail_route(methods=["GET"]) + def watched(self, request, *args, **kwargs): + for_user = get_object_or_404(models.User, **kwargs) + from_user = request.user + self.check_permissions(request, 'watched', for_user) + filters = { + "type": request.GET.get("type", None), + "q": request.GET.get("q", None), + } + + self.object_list = services.get_watched_list(for_user, from_user, **filters) + page = self.paginate_queryset(self.object_list) + elements = page.object_list if page is not None else self.object_list + + extra_args_liked = { + "user_watching": services.get_watched_content_for_user(request.user), + "user_likes": services.get_liked_content_for_user(request.user), + } + + extra_args_voted = { + "user_watching": services.get_watched_content_for_user(request.user), + "user_votes": services.get_voted_content_for_user(request.user), + } + + response_data = [] + for elem in elements: + if elem["type"] == "project": + # projects are liked objects + response_data.append(serializers.LikedObjectSerializer(into_namedtuple(elem), **extra_args_liked).data) + else: + # stories, tasks and issues are voted objects + response_data.append(serializers.VotedObjectSerializer(into_namedtuple(elem), **extra_args_voted).data) + + return response.Ok(response_data) + + @detail_route(methods=["GET"]) + def liked(self, request, *args, **kwargs): + for_user = get_object_or_404(models.User, **kwargs) + from_user = request.user + self.check_permissions(request, 'liked', for_user) + filters = { + "q": request.GET.get("q", None), + } + + self.object_list = services.get_liked_list(for_user, from_user, **filters) + page = self.paginate_queryset(self.object_list) + elements = page.object_list if page is not None else self.object_list + + extra_args = { + "user_watching": services.get_watched_content_for_user(request.user), + "user_likes": services.get_liked_content_for_user(request.user), + } + + response_data = [serializers.LikedObjectSerializer(into_namedtuple(elem), **extra_args).data for elem in elements] + + return response.Ok(response_data) + + @detail_route(methods=["GET"]) + def voted(self, request, *args, **kwargs): + for_user = get_object_or_404(models.User, **kwargs) + from_user = request.user + self.check_permissions(request, 'liked', for_user) + filters = { + "type": request.GET.get("type", None), + "q": request.GET.get("q", None), + } + + self.object_list = services.get_voted_list(for_user, from_user, **filters) + page = self.paginate_queryset(self.object_list) + elements = page.object_list if page is not None else self.object_list + + extra_args = { + "user_watching": services.get_watched_content_for_user(request.user), + "user_votes": services.get_voted_content_for_user(request.user), + } + + response_data = [serializers.VotedObjectSerializer(into_namedtuple(elem), **extra_args).data for elem in elements] + + return response.Ok(response_data) + + +###################################################### +# Role +###################################################### +class RolesViewSet(BlockedByProjectMixin, ModelCrudViewSet): + model = models.Role + serializer_class = serializers.RoleSerializer + validator_class = validators.RoleValidator + permission_classes = (permissions.RolesPermission, ) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + + def pre_delete(self, obj): + move_to = self.request.QUERY_PARAMS.get('moveTo', None) + if move_to: + membership_model = apps.get_model("projects", "Membership") + role_dest = get_object_or_404(self.model, project=obj.project, id=move_to) + qs = membership_model.objects.filter(project_id=obj.project.pk, role=obj) + qs.update(role=role_dest) + + super().pre_delete(obj) diff --git a/taiga/users/filters.py b/taiga/users/filters.py new file mode 100644 index 000000000..a0a816abf --- /dev/null +++ b/taiga/users/filters.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.filters import PermissionBasedFilterBackend +from taiga.base.utils.db import to_tsquery + +from . import services + + +class ContactsFilterBackend(PermissionBasedFilterBackend): + def filter_queryset(self, user, request, queryset, view): + qs = user.contacts_visible_by_user(request.user) + + exclude_project = request.QUERY_PARAMS.get('exclude_project', None) + if exclude_project: + qs = qs.exclude(projects__id=exclude_project) + + q = request.QUERY_PARAMS.get('q', None) + if q: + table = qs.model._meta.db_table + where_clause = (""" + to_tsvector('simple', + coalesce({table}.username, '') || ' ' || + coalesce({table}.full_name) || ' ' || + coalesce({table}.email, '')) @@ to_tsquery('simple', %s) + """.format(table=table)) + + qs = qs.extra(where=[where_clause], params=[to_tsquery(q)]) + + return qs.distinct() diff --git a/taiga/users/fixtures/initial_user.json b/taiga/users/fixtures/initial_user.json new file mode 100644 index 000000000..1b37eea2e --- /dev/null +++ b/taiga/users/fixtures/initial_user.json @@ -0,0 +1,24 @@ +[ + { + "model": "users.user", + "fields": { + "username": "admin", + "full_name": "Administrator", + "bio": "", + "lang": "", + "color": "", + "photo": "", + "is_active": true, + "colorize_tags": false, + "timezone": "", + "is_superuser": true, + "is_staff": true, + "token": "", + "last_login": "2013-04-04T07:36:09.880Z", + "password": "pbkdf2_sha256$10000$oRIbCKOL1U3w$/gaYMnOlc/GnN4mn3UUXvXpk2Hx0vvht6Uqhu46aikI=", + "email": "admin@admin.com", + "date_joined": "2013-04-01T13:48:21.711Z", + "date_cancelled": null + } + } +] diff --git a/taiga/users/forms.py b/taiga/users/forms.py new file mode 100644 index 000000000..f826dae6f --- /dev/null +++ b/taiga/users/forms.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django import forms +from django.contrib.auth.forms import UserCreationForm as DjangoUserCreationForm +from django.contrib.auth.forms import UserChangeForm as DjangoUserChangeForm + + +from .models import User + + +class UserCreationForm(DjangoUserCreationForm): + def clean_username(self): + # Since User.username is unique, this check is redundant, + # but it sets a nicer error message than the ORM. See #13147. + username = self.cleaned_data["username"] + try: + User._default_manager.get(username=username) + except User.DoesNotExist: + return username + raise forms.ValidationError(self.error_messages['duplicate_username']) + + class Meta: + model = User + fields = ('username', 'email') + + +class UserChangeForm(DjangoUserChangeForm): + class Meta: + model = User + fields = '__all__' diff --git a/taiga/users/gravatar.py b/taiga/users/gravatar.py new file mode 100644 index 000000000..1cd8f8ef5 --- /dev/null +++ b/taiga/users/gravatar.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import hashlib + + +def get_gravatar_id(email: str) -> str: + """Get the gravatar id associated to an email. + + :return: Gravatar id. + """ + + return hashlib.md5(email.lower().encode()).hexdigest() + +def get_user_gravatar_id(user: object) -> str: + """Get the gravatar id associated to a user. + + :return: Gravatar id. + """ + if user and user.email: + return get_gravatar_id(user.email) + + return None diff --git a/taiga/users/migrations/0001_initial.py b/taiga/users/migrations/0001_initial.py new file mode 100644 index 000000000..ee583d227 --- /dev/null +++ b/taiga/users/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone +import re +import django.core.validators +import taiga.users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', serialize=False, primary_key=True)), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(max_length=30, help_text='Required. 30 characters or fewer. Letters, numbers and /./-/_ characters', verbose_name='username', unique=True, validators=[django.core.validators.RegexValidator(re.compile('^[\\w.-]+$', 32), 'Enter a valid username.', 'invalid')])), + ('email', models.EmailField(max_length=75, blank=True, verbose_name='email address')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('full_name', models.CharField(max_length=256, blank=True, verbose_name='full name')), + ('color', models.CharField(default=taiga.users.models.generate_random_hex_color, max_length=9, blank=True, verbose_name='color')), + ('bio', models.TextField(default='', blank=True, verbose_name='biography')), + ('photo', models.FileField(null=True, max_length=500, blank=True, verbose_name='photo', upload_to='users/photo')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('default_language', models.CharField(default='', max_length=20, blank=True, verbose_name='default language')), + ('default_timezone', models.CharField(default='', max_length=20, blank=True, verbose_name='default timezone')), + ('colorize_tags', models.BooleanField(default=False, verbose_name='colorize tags')), + ('token', models.CharField(default=None, max_length=200, blank=True, verbose_name='token', null=True)), + ('email_token', models.CharField(default=None, max_length=200, blank=True, verbose_name='email token', null=True)), + ('new_email', models.EmailField(null=True, max_length=75, blank=True, verbose_name='new email address')), + ('github_id', models.IntegerField(null=True, blank=True, verbose_name='github ID')), + ], + options={ + 'verbose_name_plural': 'users', + 'verbose_name': 'user', + 'ordering': ['username'], + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/users/migrations/0002_auto_20140903_0916.py b/taiga/users/migrations/0002_auto_20140903_0916.py new file mode 100644 index 000000000..40375be64 --- /dev/null +++ b/taiga/users/migrations/0002_auto_20140903_0916.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', serialize=False, primary_key=True)), + ('name', models.CharField(verbose_name='name', max_length=200)), + ('slug', models.SlugField(verbose_name='slug', max_length=250, blank=True)), + ('permissions', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('vote_issues', 'Vote issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='permissions')), + ('order', models.IntegerField(verbose_name='order', default=10)), + ('computable', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'role', + 'verbose_name_plural': 'roles', + 'ordering': ['order', 'slug'], + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/users/migrations/0003_auto_20140903_0925.py b/taiga/users/migrations/0003_auto_20140903_0925.py new file mode 100644 index 000000000..35c730049 --- /dev/null +++ b/taiga/users/migrations/0003_auto_20140903_0925.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0001_initial'), + ('users', '0002_auto_20140903_0916'), + ] + + operations = [ + migrations.AddField( + model_name='role', + name='project', + field=models.ForeignKey(related_name='roles', verbose_name='project', null=True, to='projects.Project', on_delete=models.CASCADE), + preserve_default=True, + ), + migrations.AlterUniqueTogether( + name='role', + unique_together=set([('slug', 'project')]), + ), + ] diff --git a/taiga/users/migrations/0004_auto_20140913_1914.py b/taiga/users/migrations/0004_auto_20140913_1914.py new file mode 100644 index 000000000..204ba223d --- /dev/null +++ b/taiga/users/migrations/0004_auto_20140913_1914.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.core.validators +import re + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_auto_20140903_0925'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(blank=True, unique=True, max_length=255, verbose_name='email address'), + ), + migrations.AlterField( + model_name='user', + name='username', + field=models.CharField(validators=[django.core.validators.RegexValidator(re.compile('^[\\w.-]+$', 32), 'Enter a valid username.', 'invalid')], max_length=255, unique=True, help_text='Required. 30 characters or fewer. Letters, numbers and /./-/_ characters', verbose_name='username'), + ), + ] diff --git a/taiga/users/migrations/0005_alter_user_photo.py b/taiga/users/migrations/0005_alter_user_photo.py new file mode 100644 index 000000000..aaf30c010 --- /dev/null +++ b/taiga/users/migrations/0005_alter_user_photo.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_auto_20140913_1914'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='photo', + field=models.FileField(upload_to=taiga.users.models.get_user_file_path, blank=True, max_length=500, verbose_name='photo', null=True), + ), + ] diff --git a/taiga/users/migrations/0006_auto_20141030_1132.py b/taiga/users/migrations/0006_auto_20141030_1132.py new file mode 100644 index 000000000..65ef3acd3 --- /dev/null +++ b/taiga/users/migrations/0006_auto_20141030_1132.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_alter_user_photo'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_system', + field=models.BooleanField(default=False), + preserve_default=True, + ), + migrations.AlterField( + model_name='user', + name='github_id', + field=models.IntegerField(blank=True, null=True, db_index=True, verbose_name='github ID'), + ), + ] diff --git a/taiga/users/migrations/0007_auto_20150209_1611.py b/taiga/users/migrations/0007_auto_20150209_1611.py new file mode 100644 index 000000000..512cf70df --- /dev/null +++ b/taiga/users/migrations/0007_auto_20150209_1611.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import taiga.base.db.models.fields + + +def migrate_github_id(apps, schema_editor): + AuthData = apps.get_model("users", "AuthData") + User = apps.get_model("users", "User") + for user in User.objects.all(): + if user.github_id: + AuthData.objects.create(user=user, key="github", value=user.github_id, extra={}) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_auto_20141030_1132'), + ] + + operations = [ + migrations.CreateModel( + name='AuthData', + fields=[ + ('id', models.AutoField(primary_key=True, verbose_name='ID', serialize=False, auto_created=True)), + ('key', models.SlugField()), + ('value', models.CharField(max_length=300)), + ('extra', taiga.base.db.models.fields.JSONField()), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='authdata', + unique_together=set([('key', 'value')]), + ), + migrations.RunPython(migrate_github_id), + migrations.RemoveField( + model_name='user', + name='github_id', + ), + ] diff --git a/taiga/users/migrations/0008_auto_20150213_1701.py b/taiga/users/migrations/0008_auto_20150213_1701.py new file mode 100644 index 000000000..985f7f968 --- /dev/null +++ b/taiga/users/migrations/0008_auto_20150213_1701.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_auto_20150209_1611'), + ] + + operations = [ + migrations.AlterField( + model_name='authdata', + name='user', + field=models.ForeignKey(related_name='auth_data', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), + ), + ] diff --git a/taiga/users/migrations/0009_auto_20150326_1241.py b/taiga/users/migrations/0009_auto_20150326_1241.py new file mode 100644 index 000000000..fa4fbf4e1 --- /dev/null +++ b/taiga/users/migrations/0009_auto_20150326_1241.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_auto_20150213_1701'), + ] + + operations = [ + migrations.RenameField( + model_name='user', + old_name='default_language', + new_name='lang', + ), + migrations.RenameField( + model_name='user', + old_name='default_timezone', + new_name='timezone', + ), + ] diff --git a/taiga/users/migrations/0010_auto_20150414_0936.py b/taiga/users/migrations/0010_auto_20150414_0936.py new file mode 100644 index 000000000..8251dc5eb --- /dev/null +++ b/taiga/users/migrations/0010_auto_20150414_0936.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0009_auto_20150326_1241'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='lang', + field=models.CharField(max_length=20, blank=True, null=True, default='', verbose_name='default language'), + preserve_default=True, + ), + migrations.AlterField( + model_name='user', + name='timezone', + field=models.CharField(max_length=20, blank=True, null=True, default='', verbose_name='default timezone'), + preserve_default=True, + ), + ] diff --git a/taiga/users/migrations/0011_user_theme.py b/taiga/users/migrations/0011_user_theme.py new file mode 100644 index 000000000..bb0e99a5d --- /dev/null +++ b/taiga/users/migrations/0011_user_theme.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0010_auto_20150414_0936'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='theme', + field=models.CharField(null=True, blank=True, max_length=100, default='', verbose_name='default theme'), + preserve_default=True, + ), + ] diff --git a/taiga/users/migrations/0012_auto_20150812_1142.py b/taiga/users/migrations/0012_auto_20150812_1142.py new file mode 100644 index 000000000..f16af6e79 --- /dev/null +++ b/taiga/users/migrations/0012_auto_20150812_1142.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0011_user_theme'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('star_project', 'Star project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('vote_us', 'Vote user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('vote_task', 'Vote task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('vote_issue', 'Vote issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='permissions'), + preserve_default=True, + ), + ] diff --git a/taiga/users/migrations/0013_auto_20150901_1600.py b/taiga/users/migrations/0013_auto_20150901_1600.py new file mode 100644 index 000000000..001b02026 --- /dev/null +++ b/taiga/users/migrations/0013_auto_20150901_1600.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0012_auto_20150812_1142'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='permissions'), + preserve_default=True, + ), + ] diff --git a/taiga/users/migrations/0014_auto_20151005_1357.py b/taiga/users/migrations/0014_auto_20151005_1357.py new file mode 100644 index 000000000..9461fcc42 --- /dev/null +++ b/taiga/users/migrations/0014_auto_20151005_1357.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.auth.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0013_auto_20150901_1600'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.AlterField( + model_name='user', + name='last_login', + field=models.DateTimeField(verbose_name='last login', blank=True, null=True), + ), + migrations.AlterField( + model_name='user', + name='new_email', + field=models.EmailField(verbose_name='new email address', blank=True, null=True, max_length=254), + ), + ] diff --git a/taiga/users/migrations/0015_auto_20160120_1409.py b/taiga/users/migrations/0015_auto_20160120_1409.py new file mode 100644 index 000000000..f34ed5b09 --- /dev/null +++ b/taiga/users/migrations/0015_auto_20160120_1409.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0014_auto_20151005_1357'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='max_private_projects', + field=models.IntegerField(null=True, verbose_name='max number of owned private projects', default=settings.MAX_PRIVATE_PROJECTS_PER_USER, blank=True), + ), + migrations.AddField( + model_name='user', + name='max_public_projects', + field=models.IntegerField(null=True, verbose_name='max number of owned public projects', default=settings.MAX_PUBLIC_PROJECTS_PER_USER, blank=True), + ), + ] diff --git a/taiga/users/migrations/0016_auto_20160204_1050.py b/taiga/users/migrations/0016_auto_20160204_1050.py new file mode 100644 index 000000000..b30419b20 --- /dev/null +++ b/taiga/users/migrations/0016_auto_20160204_1050.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0015_auto_20160120_1409'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='max_memberships_private_projects', + field=models.IntegerField(default=settings.MAX_MEMBERSHIPS_PRIVATE_PROJECTS, blank=True, verbose_name='max number of memberships for each owned private project', null=True), + ), + migrations.AddField( + model_name='user', + name='max_memberships_public_projects', + field=models.IntegerField(default=settings.MAX_MEMBERSHIPS_PUBLIC_PROJECTS, blank=True, verbose_name='max number of memberships for each owned public project', null=True), + ), + ] diff --git a/taiga/users/migrations/0017_auto_20160208_1751.py b/taiga/users/migrations/0017_auto_20160208_1751.py new file mode 100644 index 000000000..7fb7fa0de --- /dev/null +++ b/taiga/users/migrations/0017_auto_20160208_1751.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0016_auto_20160204_1050'), + ] + + operations = [ + migrations.AlterModelOptions( + name='role', + options={'ordering': ['order', 'slug'], 'verbose_name': 'role', 'verbose_name_plural': 'roles'}, + ), + migrations.AlterModelOptions( + name='user', + options={'ordering': ['username'], 'verbose_name': 'user', 'verbose_name_plural': 'users'}, + ), + ] diff --git a/taiga/users/migrations/0018_remove_vote_issues_in_roles_permissions_field.py b/taiga/users/migrations/0018_remove_vote_issues_in_roles_permissions_field.py new file mode 100644 index 000000000..8c9f6133e --- /dev/null +++ b/taiga/users/migrations/0018_remove_vote_issues_in_roles_permissions_field.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-04-04 09:32 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0017_auto_20160208_1751'), + ] + + operations = [ + migrations.RunSQL( + "UPDATE users_role SET permissions = ARRAY_REMOVE(permissions, 'vote_issues')" + ), + ] diff --git a/taiga/users/migrations/0019_auto_20160519_1058.py b/taiga/users/migrations/0019_auto_20160519_1058.py new file mode 100644 index 000000000..17bfbb095 --- /dev/null +++ b/taiga/users/migrations/0019_auto_20160519_1058.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-05-19 10:58 +from __future__ import unicode_literals + +from django.db import models, migrations +import django.contrib.postgres.fields + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0018_remove_vote_issues_in_roles_permissions_field'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='permissions'), + ), + ] diff --git a/taiga/users/migrations/0020_auto_20160525_1229.py b/taiga/users/migrations/0020_auto_20160525_1229.py new file mode 100644 index 000000000..ac8f05c3e --- /dev/null +++ b/taiga/users/migrations/0020_auto_20160525_1229.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-05-25 12:29 +from __future__ import unicode_literals + +from django.db import migrations + + +UPDATE_ROLES_PERMISSIONS_SQL = """ + UPDATE users_role + SET + PERMISSIONS = array_append(PERMISSIONS, '{comment_permission}') + WHERE + '{base_permission}' = ANY(PERMISSIONS) + AND + NOT '{comment_permission}' = ANY(PERMISSIONS) +""" + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0019_auto_20160519_1058'), + ] + + operations = [ + # user stories + migrations.RunSQL(UPDATE_ROLES_PERMISSIONS_SQL.format( + base_permission="modify_us", + comment_permission="comment_us") + ), + + # tasks + migrations.RunSQL(UPDATE_ROLES_PERMISSIONS_SQL.format( + base_permission="modify_task", + comment_permission="comment_task") + ), + + # issues + migrations.RunSQL(UPDATE_ROLES_PERMISSIONS_SQL.format( + base_permission="modify_issue", + comment_permission="comment_issue") + ), + + # wiki pages + migrations.RunSQL(UPDATE_ROLES_PERMISSIONS_SQL.format( + base_permission="modify_wiki_page", + comment_permission="comment_wiki_page") + ) + ] diff --git a/taiga/users/migrations/0021_auto_20160614_1201.py b/taiga/users/migrations/0021_auto_20160614_1201.py new file mode 100644 index 000000000..de1f701f8 --- /dev/null +++ b/taiga/users/migrations/0021_auto_20160614_1201.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-14 12:01 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0020_auto_20160525_1229'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='permissions'), + ), + ] diff --git a/taiga/users/migrations/0022_auto_20160629_1443.py b/taiga/users/migrations/0022_auto_20160629_1443.py new file mode 100644 index 000000000..efd5f810f --- /dev/null +++ b/taiga/users/migrations/0022_auto_20160629_1443.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.9.2 on 2016-06-29 14:43 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0021_auto_20160614_1201'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='permissions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_epics', 'View epic'), ('add_epic', 'Add epic'), ('modify_epic', 'Modify epic'), ('comment_epic', 'Comment epic'), ('delete_epic', 'Delete epic'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('comment_us', 'Comment user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('comment_task', 'Comment task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('comment_issue', 'Comment issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('comment_wiki_page', 'Comment wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')]), blank=True, default=list, null=True, size=None, verbose_name='permissions'), + ), + ] diff --git a/taiga/users/migrations/0023_json_to_jsonb.py b/taiga/users/migrations/0023_json_to_jsonb.py new file mode 100644 index 000000000..c515d6b47 --- /dev/null +++ b/taiga/users/migrations/0023_json_to_jsonb.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.2 on 2016-10-26 11:35 +from __future__ import unicode_literals + +from django.db import migrations +from django.contrib.postgres.fields import JSONField + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0022_auto_20160629_1443'), + ] + + operations = [ + migrations.RunSQL( + """ + ALTER TABLE "{table_name}" + ALTER COLUMN "{column_name}" + TYPE jsonb + USING regexp_replace("{column_name}"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """.format( + table_name="users_authdata", + column_name="extra", + ), + reverse_sql=migrations.RunSQL.noop + ), + ] diff --git a/taiga/users/migrations/0024_auto_20170406_0727.py b/taiga/users/migrations/0024_auto_20170406_0727.py new file mode 100644 index 000000000..fc4e664cb --- /dev/null +++ b/taiga/users/migrations/0024_auto_20170406_0727.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.6 on 2017-04-06 07:27 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0023_json_to_jsonb'), + ] + + operations = [ + migrations.RunSQL("CREATE INDEX ON users_user (UPPER('username'))"), + migrations.RunSQL("CREATE INDEX ON users_user (UPPER('email'))"), + ] diff --git a/taiga/users/migrations/0025_user_uuid.py b/taiga/users/migrations/0025_user_uuid.py new file mode 100644 index 000000000..f506f297b --- /dev/null +++ b/taiga/users/migrations/0025_user_uuid.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.2 on 2017-10-31 14:57 +from __future__ import unicode_literals + +from django.db import migrations, models +import taiga.users.models +import uuid + + +def update_uuids(apps, schema_editor): + User = apps.get_model("users", "User") + for user in User.objects.all(): + user.uuid = uuid.uuid4().hex + user.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0024_auto_20170406_0727'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='uuid', + field=models.CharField(default=taiga.users.models.get_default_uuid, editable=False, max_length=32), + ), + migrations.RunPython(update_uuids, lambda apps, schema_editor: None), + migrations.AlterField( + model_name='user', + name='uuid', + field=models.CharField(default=taiga.users.models.get_default_uuid, editable=False, max_length=32, unique=True), + ), + ] diff --git a/taiga/users/migrations/0026_auto_20180514_1513.py b/taiga/users/migrations/0026_auto_20180514_1513.py new file mode 100644 index 000000000..183fb8ce9 --- /dev/null +++ b/taiga/users/migrations/0026_auto_20180514_1513.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.2 on 2018-05-14 15:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0025_user_uuid'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='accepted_terms', + field=models.BooleanField(default=True, verbose_name='accepted terms'), + ), + migrations.AddField( + model_name='user', + name='read_new_terms', + field=models.BooleanField(default=False, verbose_name='read new terms'), + ), + ] diff --git a/taiga/users/migrations/0027_auto_20180610_2011.py b/taiga/users/migrations/0027_auto_20180610_2011.py new file mode 100644 index 000000000..ce86d3a71 --- /dev/null +++ b/taiga/users/migrations/0027_auto_20180610_2011.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.11.2 on 2018-06-10 20:11 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0026_auto_20180514_1513'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='read_new_terms', + field=models.BooleanField(default=False, verbose_name='new terms read'), + ), + ] diff --git a/taiga/users/migrations/0028_auto_20200615_0811.py b/taiga/users/migrations/0028_auto_20200615_0811.py new file mode 100644 index 000000000..618c4d926 --- /dev/null +++ b/taiga/users/migrations/0028_auto_20200615_0811.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.12 on 2020-06-15 08:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0027_auto_20180610_2011'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='colorize_tags', + field=models.BooleanField(blank=True, default=False, verbose_name='colorize tags'), + ), + ] diff --git a/taiga/users/migrations/0029_user_verified_email.py b/taiga/users/migrations/0029_user_verified_email.py new file mode 100644 index 000000000..092803a93 --- /dev/null +++ b/taiga/users/migrations/0029_user_verified_email.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.14 on 2020-07-30 12:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0028_auto_20200615_0811'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='verified_email', + field=models.BooleanField(default=True), + ), + ] diff --git a/taiga/users/migrations/0030_auto_20201119_1031.py b/taiga/users/migrations/0030_auto_20201119_1031.py new file mode 100644 index 000000000..8dbd418b0 --- /dev/null +++ b/taiga/users/migrations/0030_auto_20201119_1031.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.17 on 2020-11-19 10:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0029_user_verified_email'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ], + ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(max_length=255, unique=True, verbose_name='email address'), + ), + ] diff --git a/taiga/users/migrations/0031_auto_20210108_1430.py b/taiga/users/migrations/0031_auto_20210108_1430.py new file mode 100644 index 000000000..fd22b28c8 --- /dev/null +++ b/taiga/users/migrations/0031_auto_20210108_1430.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 2.2.17 on 2021-01-08 14:30 + +import django.contrib.auth.models +from django.db import migrations, models + + +def update_is_staff(apps, schema_editor): + user_model = apps.get_model("users", "User") + user_model.objects.filter(is_superuser=True).update(is_staff=True) + + +def empty_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0030_auto_20201119_1031'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.AddField( + model_name='user', + name='is_staff', + field=models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status'), + ), + migrations.RunPython(update_is_staff, empty_reverse) + ] diff --git a/taiga/users/migrations/0032_user_date_cancelled.py b/taiga/users/migrations/0032_user_date_cancelled.py new file mode 100644 index 000000000..db8bc2d6f --- /dev/null +++ b/taiga/users/migrations/0032_user_date_cancelled.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.19 on 2021-07-05 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0031_auto_20210108_1430'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='date_cancelled', + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='date cancelled'), + ), + ] diff --git a/taiga/users/migrations/0033_auto_20211110_1526.py b/taiga/users/migrations/0033_auto_20211110_1526.py new file mode 100644 index 000000000..8d453f0f5 --- /dev/null +++ b/taiga/users/migrations/0033_auto_20211110_1526.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.24 on 2021-11-10 15:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0032_user_date_cancelled'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='max_memberships_private_projects', + field=models.IntegerField(blank=True, default=None, null=True, verbose_name='max number of memberships of different users for all owned private project'), + ), + migrations.AlterField( + model_name='user', + name='max_memberships_public_projects', + field=models.IntegerField(blank=True, default=None, null=True, verbose_name='max number of memberships of different users for all owned public project'), + ), + ] diff --git a/taiga/users/migrations/__init__.py b/taiga/users/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/users/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/users/models.py b/taiga/users/models.py new file mode 100644 index 000000000..eb4171724 --- /dev/null +++ b/taiga/users/models.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from importlib import import_module + +import random +import uuid +import re + +from django.apps import apps +from django.apps.config import MODELS_MODULE_NAME +from django.conf import settings +from django.contrib.auth.models import AbstractBaseUser, UserManager +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField +from django.core import validators +from django.core.exceptions import AppRegistryNotReady +from django.db import models +from django.dispatch import receiver +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from taiga.base.db.models.fields import JSONField +from django_pglocks import advisory_lock + +from taiga.base.utils.colors import generate_random_hex_color +from taiga.base.utils.slug import slugify_uniquely +from taiga.base.utils.files import get_file_path +from taiga.base.utils.time import timestamp_ms +from taiga.permissions.choices import MEMBERS_PERMISSIONS +from taiga.projects.choices import BLOCKED_BY_OWNER_LEAVING +from taiga.projects.notifications.choices import NotifyLevel + +from . import services + + +def get_user_model_safe(): + """ + Fetches the user model using the app registry. + This doesn't require that an app with the given app label exists, + which makes it safe to call when the registry is being populated. + All other methods to access models might raise an exception about the + registry not being ready yet. + Raises LookupError if model isn't found. + + Based on: https://github.com/django-oscar/django-oscar/blob/1.0/oscar/core/loading.py#L310-L340 + Ongoing Django issue: https://code.djangoproject.com/ticket/22872 + """ + user_app, user_model = settings.AUTH_USER_MODEL.split('.') + + try: + return apps.get_model(user_app, user_model) + except AppRegistryNotReady: + if apps.apps_ready and not apps.models_ready: + # If this function is called while `apps.populate()` is + # loading models, ensure that the module that defines the + # target model has been imported and try looking the model up + # in the app registry. This effectively emulates + # `from path.to.app.models import Model` where we use + # `Model = get_model('app', 'Model')` instead. + app_config = apps.get_app_config(user_app) + # `app_config.import_models()` cannot be used here because it + # would interfere with `apps.populate()`. + import_module('%s.%s' % (app_config.name, MODELS_MODULE_NAME)) + # In order to account for case-insensitivity of model_name, + # look up the model through a private API of the app registry. + return apps.get_registered_model(user_app, user_model) + else: + # This must be a different case (e.g. the model really doesn't + # exist). We just re-raise the exception. + raise + + +def get_user_file_path(instance, filename): + return get_file_path(instance, filename, "user") + + +class PermissionsMixin(models.Model): + """ + A mixin class that adds the fields and methods necessary to support + Django"s Permission model using the ModelBackend. + """ + is_superuser = models.BooleanField(_("superuser status"), default=False, + help_text=_("Designates that this user has all permissions without " + "explicitly assigning them.")) + + class Meta: + abstract = True + + def has_perm(self, perm, obj=None): + """ + Returns True if the user is superadmin and is active + """ + return self.is_active and self.is_superuser + + def has_perms(self, perm_list, obj=None): + """ + Returns True if the user is superadmin and is active + """ + return self.is_active and self.is_superuser + + def has_module_perms(self, app_label): + """ + Returns True if the user is superadmin and is active + """ + return self.is_active and self.is_superuser + + +def get_default_uuid(): + return uuid.uuid4().hex + + +class User(AbstractBaseUser, PermissionsMixin): + uuid = models.CharField(max_length=32, editable=False, null=False, + blank=False, unique=True, default=get_default_uuid) + username = models.CharField(_("username"), max_length=255, unique=True, + help_text=_("Required. 30 characters or fewer. Letters, numbers and " + "/./-/_ characters"), + validators=[ + validators.RegexValidator(re.compile(r"^[\w.-]+$"), _("Enter a valid username."), "invalid") + ]) + email = models.EmailField(_("email address"), max_length=255, null=False, blank=False, unique=True) + is_active = models.BooleanField(_("active"), default=True, + help_text=_("Designates whether this user should be treated as " + "active. Unselect this instead of deleting accounts.")) + is_staff = models.BooleanField(_('staff status'), default=False, + help_text=_('Designates whether the user can log into this admin site.'), + ) + + full_name = models.CharField(_("full name"), max_length=256, blank=True) + color = models.CharField(max_length=9, null=False, blank=True, default=generate_random_hex_color, + verbose_name=_("color")) + bio = models.TextField(null=False, blank=True, default="", verbose_name=_("biography")) + photo = models.FileField(upload_to=get_user_file_path, + max_length=500, null=True, blank=True, + verbose_name=_("photo")) + date_joined = models.DateTimeField(_("date joined"), default=timezone.now) + date_cancelled = models.DateTimeField(_("date cancelled"), null=True, blank=True, default=None) + accepted_terms = models.BooleanField(_("accepted terms"), default=True) + read_new_terms = models.BooleanField(_("new terms read"), default=False) + lang = models.CharField(max_length=20, null=True, blank=True, default="", + verbose_name=_("default language")) + theme = models.CharField(max_length=100, null=True, blank=True, default="", + verbose_name=_("default theme")) + timezone = models.CharField(max_length=20, null=True, blank=True, default="", + verbose_name=_("default timezone")) + colorize_tags = models.BooleanField(null=False, blank=True, default=False, + verbose_name=_("colorize tags")) + token = models.CharField(max_length=200, null=True, blank=True, default=None, + verbose_name=_("token")) + + email_token = models.CharField(max_length=200, null=True, blank=True, default=None, + verbose_name=_("email token")) + + new_email = models.EmailField(_("new email address"), null=True, blank=True) + verified_email = models.BooleanField(null=False, blank=False, default=True) + is_system = models.BooleanField(null=False, blank=False, default=False) + + + max_private_projects = models.IntegerField(null=True, blank=True, + default=settings.MAX_PRIVATE_PROJECTS_PER_USER, + verbose_name=_("max number of owned private projects")) + max_public_projects = models.IntegerField(null=True, blank=True, + default=settings.MAX_PUBLIC_PROJECTS_PER_USER, + verbose_name=_("max number of owned public projects")) + max_memberships_private_projects = models.IntegerField(null=True, blank=True, + default=settings.MAX_MEMBERSHIPS_PRIVATE_PROJECTS, + verbose_name=_("max number of memberships of " + "different users for all owned " + "private project")) + max_memberships_public_projects = models.IntegerField(null=True, blank=True, + default=settings.MAX_MEMBERSHIPS_PUBLIC_PROJECTS, + verbose_name=_("max number of memberships of " + "different users for all owned " + "public project")) + + _cached_memberships = None + _cached_liked_ids = None + _cached_watched_ids = None + _cached_notify_levels = None + + USERNAME_FIELD = "username" + REQUIRED_FIELDS = ["email"] + + objects = UserManager() + + class Meta: + verbose_name = "user" + verbose_name_plural = "users" + ordering = ["username"] + + def __str__(self): + return self.get_full_name() + + def _fill_cached_memberships(self): + self._cached_memberships = {} + qs = self.memberships.select_related("user", "project", "role") + for membership in qs.all(): + self._cached_memberships[membership.project.id] = membership + + @property + def cached_memberships(self): + if self._cached_memberships is None: + self._fill_cached_memberships() + + return self._cached_memberships.values() + + def cached_membership_for_project(self, project): + if self._cached_memberships is None: + self._fill_cached_memberships() + + return self._cached_memberships.get(project.id, None) + + def is_fan(self, obj): + if self._cached_liked_ids is None: + self._cached_liked_ids = set() + for like in self.likes.select_related("content_type").all(): + like_id = "{}-{}".format(like.content_type.id, like.object_id) + self._cached_liked_ids.add(like_id) + + obj_type = ContentType.objects.get_for_model(obj) + obj_id = "{}-{}".format(obj_type.id, obj.id) + return obj_id in self._cached_liked_ids + + def is_watcher(self, obj): + if self._cached_watched_ids is None: + self._cached_watched_ids = set() + for watched in self.watched.select_related("content_type").all(): + watched_id = "{}-{}".format(watched.content_type.id, watched.object_id) + self._cached_watched_ids.add(watched_id) + + notify_policies = self.notify_policies.select_related("project")\ + .exclude(notify_level=NotifyLevel.none) + + for notify_policy in notify_policies: + obj_type = ContentType.objects.get_for_model(notify_policy.project) + watched_id = "{}-{}".format(obj_type.id, notify_policy.project.id) + self._cached_watched_ids.add(watched_id) + + obj_type = ContentType.objects.get_for_model(obj) + obj_id = "{}-{}".format(obj_type.id, obj.id) + return obj_id in self._cached_watched_ids + + def get_notify_level(self, project): + if self._cached_notify_levels is None: + self._cached_notify_levels = {} + for notify_policy in self.notify_policies.select_related("project"): + self._cached_notify_levels[notify_policy.project.id] = notify_policy.notify_level + + return self._cached_notify_levels.get(project.id, None) + + def get_short_name(self): + "Returns the short name for the user." + return self.username + + def get_full_name(self): + return self.full_name or self.username or self.email + + def contacts_visible_by_user(self, user): + qs = User.objects.filter(is_active=True) + project_ids = services.get_visible_project_ids(self, user) + qs = qs.filter(memberships__project_id__in=project_ids) + qs = qs.exclude(id=self.id) + return qs + + def cancel(self): + with advisory_lock("delete-user"): + deleted_user_prefix = "deleted-user-{}".format(timestamp_ms()) + self.username = slugify_uniquely(deleted_user_prefix, User, slugfield="username") + self.email = "{}@taiga.io".format(self.username) + self.is_active = False + self.full_name = "Deleted user" + self.color = "" + self.bio = "" + self.lang = "" + self.theme = "" + self.timezone = "" + self.colorize_tags = True + self.token = None + self.set_unusable_password() + self.photo = None + self.date_cancelled = timezone.now() + self.new_email = "{}@taiga.io".format(self.username) + self.email_token = None + self.save() + self.auth_data.all().delete() + + # Blocking all owned projects + self.owned_projects.update(blocked_code=BLOCKED_BY_OWNER_LEAVING) + + # Remove all memberships + self.memberships.all().delete() + + +class Role(models.Model): + name = models.CharField(max_length=200, null=False, blank=False, + verbose_name=_("name")) + slug = models.SlugField(max_length=250, null=False, blank=True, + verbose_name=_("slug")) + permissions = ArrayField(models.TextField(null=False, blank=False, choices=MEMBERS_PERMISSIONS), + null=True, blank=True, default=list, verbose_name=_("permissions")) + order = models.IntegerField(default=10, null=False, blank=False, + verbose_name=_("order")) + # null=True is for make work django 1.7 migrations. project + # field causes some circular dependencies, and due to this + # it can not be serialized in one transactional migration. + project = models.ForeignKey( + "projects.Project", + null=True, + blank=False, + related_name="roles", + verbose_name=_("project"), + on_delete=models.CASCADE, + ) + computable = models.BooleanField(default=True) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify_uniquely(self.name, self.__class__) + + super().save(*args, **kwargs) + + class Meta: + verbose_name = "role" + verbose_name_plural = "roles" + ordering = ["order", "slug"] + unique_together = (("slug", "project"),) + + def __str__(self): + return self.name + + +class AuthData(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="auth_data", on_delete=models.CASCADE) + key = models.SlugField(max_length=50) + value = models.CharField(max_length=300) + extra = JSONField() + + class Meta: + unique_together = ["key", "value"] + + +# On Role object is changed, update all membership +# related to current role. +@receiver(models.signals.post_save, sender=Role, + dispatch_uid="role_post_save") +def role_post_save(sender, instance, created, **kwargs): + # ignore if object is just created + if created: + return + + instance.project.update_role_points() diff --git a/taiga/users/permissions.py b/taiga/users/permissions.py new file mode 100644 index 000000000..72e28b94f --- /dev/null +++ b/taiga/users/permissions.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission +from taiga.base.api.permissions import IsSuperUser +from taiga.base.api.permissions import AllowAny +from taiga.base.api.permissions import IsAuthenticated +from taiga.base.api.permissions import HasProjectPerm +from taiga.base.api.permissions import IsProjectAdmin +from taiga.base.api.permissions import PermissionComponent +from django.conf import settings + + +class IsTheSameUser(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return obj and request.user.is_authenticated and request.user.pk == obj.pk + + +class CanRetrieveUser(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if settings.PRIVATE_USER_PROFILES: + return obj and request.user and request.user.is_authenticated() + + return True + + +class UserPermission(TaigaResourcePermission): + enough_perms = IsSuperUser() + global_perms = None + retrieve_perms = CanRetrieveUser() + by_username_perms = retrieve_perms + update_perms = IsTheSameUser() + partial_update_perms = IsTheSameUser() + destroy_perms = IsTheSameUser() + list_perms = AllowAny() + stats_perms = AllowAny() + password_recovery_perms = AllowAny() + change_password_from_recovery_perms = AllowAny() + change_password_perms = IsAuthenticated() + change_avatar_perms = IsAuthenticated() + me_perms = IsAuthenticated() + remove_avatar_perms = IsAuthenticated() + change_email_perms = AllowAny() + contacts_perms = AllowAny() + liked_perms = AllowAny() + voted_perms = AllowAny() + watched_perms = AllowAny() + send_verification_email_perms = IsAuthenticated() + + +class RolesPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py new file mode 100644 index 000000000..16425e8b9 --- /dev/null +++ b/taiga/users/serializers.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.conf import settings + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField, I18NField + +from taiga.base.utils.thumbnails import get_thumbnail_url + +from taiga.projects.models import Project +from .services import get_user_photo_url, get_user_big_photo_url +from taiga.users.gravatar import get_user_gravatar_id +from taiga.users.models import User + + +###################################################### +# User +###################################################### + +class ContactProjectDetailSerializer(serializers.LightSerializer): + id = Field() + slug = Field() + name = Field() + + +class UserSerializer(serializers.LightSerializer): + id = Field() + username = Field() + full_name = Field() + full_name_display = MethodField() + color = Field() + bio = Field() + lang = Field() + theme = Field() + timezone = Field() + is_active = Field() + photo = MethodField() + big_photo = MethodField() + gravatar_id = MethodField() + roles = MethodField() + + def get_full_name_display(self, obj): + return obj.get_full_name() if obj else "" + + def get_photo(self, user): + return get_user_photo_url(user) + + def get_big_photo(self, user): + return get_user_big_photo_url(user) + + def get_gravatar_id(self, user): + return get_user_gravatar_id(user) + + def get_roles(self, user): + if hasattr(user, "roles_attr"): + return user.roles_attr + + return user.memberships.order_by("role__name").values_list("role__name", flat=True).distinct() + + +class UserAdminSerializer(UserSerializer): + total_private_projects = MethodField() + total_public_projects = MethodField() + email = Field() + uuid = Field() + date_joined = Field() + read_new_terms = Field() + accepted_terms = Field() + max_private_projects = Field() + max_public_projects = Field() + max_memberships_private_projects = Field() + max_memberships_public_projects = Field() + verified_email = Field() + + def get_total_private_projects(self, user): + return user.owned_projects.filter(is_private=True).count() + + def get_total_public_projects(self, user): + return user.owned_projects.filter(is_private=False).count() + + +class UserBasicInfoSerializer(serializers.LightSerializer): + username = Field() + full_name_display = MethodField() + photo = MethodField() + big_photo = MethodField() + gravatar_id = MethodField() + is_active = Field() + id = Field() + + def get_full_name_display(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 to_value(self, instance): + if instance is None: + return None + + return super().to_value(instance) + + +###################################################### +# Role +###################################################### + +class RoleSerializer(serializers.LightSerializer): + id = Field() + name = Field() + slug = Field() + project = Field(attr="project_id") + order = Field() + computable = Field() + permissions = Field() + members_count = MethodField() + + def get_members_count(self, obj): + return obj.memberships.count() + + +###################################################### +# Like +###################################################### + +class HighLightedContentSerializer(serializers.LightSerializer): + type = Field() + id = Field() + ref = Field() + slug = Field() + name = Field() + subject = Field() + description = MethodField() + assigned_to = Field() + status = Field() + status_color = Field() + tags_colors = MethodField() + created_date = Field() + is_private = MethodField() + logo_small_url = MethodField() + + project = MethodField() + project_name = MethodField() + project_slug = MethodField() + project_is_private = MethodField() + project_blocked_code = Field() + + assigned_to = Field(attr="assigned_to_id") + assigned_to_extra_info = MethodField() + + is_watcher = MethodField() + total_watchers = Field() + + def __init__(self, *args, **kwargs): + # Don't pass the extra ids args up to the superclass + self.user_watching = kwargs.pop("user_watching", {}) + + # Instantiate the superclass normally + super().__init__(*args, **kwargs) + + def _none_if_project(self, obj, property): + type = getattr(obj, "type", "") + if type == "project": + return None + + return getattr(obj, property) + + def _none_if_not_project(self, obj, property): + type = getattr(obj, "type", "") + if type != "project": + return None + + return getattr(obj, property) + + def get_project(self, obj): + return self._none_if_project(obj, "project") + + def get_is_private(self, obj): + return self._none_if_not_project(obj, "project_is_private") + + def get_project_name(self, obj): + return self._none_if_project(obj, "project_name") + + def get_description(self, obj): + return self._none_if_not_project(obj, "description") + + def get_project_slug(self, obj): + return self._none_if_project(obj, "project_slug") + + def get_project_is_private(self, obj): + return self._none_if_project(obj, "project_is_private") + + def get_logo_small_url(self, obj): + logo = self._none_if_not_project(obj, "logo") + if logo: + return get_thumbnail_url(logo, settings.THN_LOGO_SMALL) + return None + + def get_assigned_to_extra_info(self, obj): + assigned_to = None + if obj.assigned_to_extra_info is not None: + assigned_to = User(**obj.assigned_to_extra_info) + return UserBasicInfoSerializer(assigned_to).data + + def get_tags_colors(self, obj): + tags = getattr(obj, "tags", []) + tags = tags if tags is not None else [] + tags_colors = getattr(obj, "tags_colors", []) + tags_colors = tags_colors if tags_colors is not None else [] + return [{"name": tc[0], "color": tc[1]} for tc in tags_colors if tc[0] in tags] + + def get_is_watcher(self, obj): + return obj.id in self.user_watching.get(obj.type, []) + + +class LikedObjectSerializer(HighLightedContentSerializer): + is_fan = MethodField() + total_fans = Field() + + def __init__(self, *args, **kwargs): + # Don't pass the extra ids args up to the superclass + self.user_likes = kwargs.pop("user_likes", {}) + + # Instantiate the superclass normally + super().__init__(*args, **kwargs) + + def get_is_fan(self, obj): + return obj.id in self.user_likes.get(obj.type, []) + + +class VotedObjectSerializer(HighLightedContentSerializer): + is_voter = MethodField() + total_voters = Field() + + def __init__(self, *args, **kwargs): + # Don't pass the extra ids args up to the superclass + self.user_votes = kwargs.pop("user_votes", {}) + + # Instantiate the superclass normally + super().__init__(*args, **kwargs) + + def get_is_voter(self, obj): + return obj.id in self.user_votes.get(obj.type, []) diff --git a/taiga/users/services.py b/taiga/users/services.py new file mode 100644 index 000000000..c38f5b77b --- /dev/null +++ b/taiga/users/services.py @@ -0,0 +1,620 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +""" +This model contains a domain logic for users application. +""" +from io import StringIO +import csv +import os +import uuid +import zipfile + +from django.apps import apps +from django.contrib.auth import get_user_model +from django.core.files.storage import default_storage +from django.db.models import OuterRef, Q, Subquery +from django.db import connection +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ + +from easy_thumbnails.files import get_thumbnailer +from easy_thumbnails.exceptions import InvalidImageFormatError + +from taiga.base import exceptions as exc +from taiga.base.utils.db import to_tsquery +from taiga.base.utils.urls import get_absolute_url +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.services import get_projects_watched + + +def get_user_by_username_or_email(username_or_email): + user_model = get_user_model() + qs = user_model.objects.filter(Q(username__iexact=username_or_email) | + Q(email__iexact=username_or_email)) + + if len(qs) > 1: + qs = qs.filter(Q(username=username_or_email) | + Q(email=username_or_email)) + + if len(qs) == 0: + raise exc.WrongArguments(_("Username or password does not matches user.")) + + user = qs[0] + return user + + +def get_and_validate_user(*, username: str, password: str) -> bool: + """ + Check if user with username/email exists and specified + password matchs well with existing user password. + + if user is valid, user is returned else, corresponding + exception is raised. + """ + + user = get_user_by_username_or_email(username) + if not user.check_password(password) or not user.is_active or user.is_system: + raise exc.WrongArguments(_("Username or password does not matches user.")) + + return user + + +def get_photo_url(photo): + """Get a photo absolute url and the photo automatically cropped.""" + if not photo: + return None + try: + url = get_thumbnailer(photo)[settings.THN_AVATAR_SMALL].url + return get_absolute_url(url) + except InvalidImageFormatError as e: + return None + + +def get_user_photo_url(user): + """Get the user's photo url.""" + if not user: + return None + return get_photo_url(user.photo) + + +def get_big_photo_url(photo): + """Get a big photo absolute url and the photo automatically cropped.""" + if not photo: + return None + try: + url = get_thumbnailer(photo)[settings.THN_AVATAR_BIG].url + return get_absolute_url(url) + except InvalidImageFormatError as e: + return None + + +def get_user_big_photo_url(user): + """Get the user's big photo url.""" + if not user: + return None + return get_big_photo_url(user.photo) + + +def get_visible_project_ids(from_user, by_user): + """Calculate the project_ids from one user visible by another""" + required_permissions = ["view_project"] + # Or condition for membership filtering, the basic one is the access to projects + # allowing anonymous visualization + member_perm_conditions = Q(project__anon_permissions__contains=required_permissions) + + # Authenticated + if by_user.is_authenticated: + # Calculating the projects wich from_user user is member + by_user_project_ids = by_user.memberships.values_list("project__id", flat=True) + # Adding to the condition two OR situations: + # - The from user has a role that allows access to the project + # - The to user is the owner + member_perm_conditions |= \ + Q(project__id__in=by_user_project_ids, role__permissions__contains=required_permissions) |\ + Q(project__id__in=by_user_project_ids, is_admin=True) + + Membership = apps.get_model('projects', 'Membership') + # Calculating the user memberships adding the permission filter for the by user + memberships_qs = Membership.objects.filter(member_perm_conditions, user=from_user) + project_ids = memberships_qs.values_list("project__id", flat=True) + return project_ids + + +def get_stats_for_user(from_user, by_user): + """Get the user stats""" + project_ids = get_visible_project_ids(from_user, by_user) + + total_num_projects = len(project_ids) + + role_names = from_user.memberships.filter(project__id__in=project_ids).values_list("role__name", flat=True) + roles = [_(r) for r in role_names] + roles = list(set(roles)) + + User = apps.get_model('users', 'User') + total_num_contacts = User.objects.filter(memberships__project__id__in=project_ids)\ + .exclude(id=from_user.id)\ + .distinct()\ + .count() + + UserStory = apps.get_model('userstories', 'UserStory') + + assigned_users_ids = UserStory.objects.order_by().filter( + assigned_users__in=[from_user], id=OuterRef('pk')).values('pk') + + total_num_closed_userstories = UserStory.objects.filter( + is_closed=True, + project__id__in=project_ids).filter( + Q(assigned_to=from_user) | Q(pk__in=Subquery(assigned_users_ids))).count() + + project_stats = { + 'total_num_projects': total_num_projects, + 'roles': roles, + 'total_num_contacts': total_num_contacts, + 'total_num_closed_userstories': total_num_closed_userstories, + } + return project_stats + + +def get_liked_content_for_user(user): + """Returns a dict where: + - The key is the content_type model + - The values are list of id's of the different objects liked by the user + """ + if user.is_anonymous: + return {} + + user_likes = {} + for (ct_model, object_id) in user.likes.values_list("content_type__model", "object_id"): + list = user_likes.get(ct_model, []) + list.append(object_id) + user_likes[ct_model] = list + + return user_likes + + +def get_voted_content_for_user(user): + """Returns a dict where: + - The key is the content_type model + - The values are list of id's of the different objects voted by the user + """ + if user.is_anonymous: + return {} + + user_votes = {} + for (ct_model, object_id) in user.votes.values_list("content_type__model", "object_id"): + list = user_votes.get(ct_model, []) + list.append(object_id) + user_votes[ct_model] = list + + return user_votes + + +def get_watched_content_for_user(user): + """Returns a dict where: + - The key is the content_type model + - The values are list of id's of the different objects watched by the user + """ + if user.is_anonymous: + return {} + + user_watches = {} + for (ct_model, object_id) in user.watched.values_list("content_type__model", "object_id"): + list = user_watches.get(ct_model, []) + list.append(object_id) + user_watches[ct_model] = list + + # Now for projects, + projects_watched = get_projects_watched(user) + project_content_type_model = ContentType.objects.get(app_label="projects", model="project").model + user_watches[project_content_type_model] = projects_watched.values_list("id", flat=True) + + return user_watches + + +def _build_watched_sql_for_projects(for_user): + sql = """ + SELECT projects_project.id AS id, null::integer AS ref, 'project'::text AS type, + tags, notifications_notifypolicy.project_id AS object_id, projects_project.id AS project, + slug, projects_project.name, null::text AS subject, + notifications_notifypolicy.created_at as created_date, + coalesce(watchers, 0) AS total_watchers, projects_project.total_fans AS total_fans, null::integer AS total_voters, + null::integer AS assigned_to, null::text as status, null::text as status_color + FROM notifications_notifypolicy + INNER JOIN projects_project + ON (projects_project.id = notifications_notifypolicy.project_id) + LEFT JOIN (SELECT project_id, count(*) watchers + FROM notifications_notifypolicy + WHERE notifications_notifypolicy.notify_level != {none_notify_level} + GROUP BY project_id + ) type_watchers + ON projects_project.id = type_watchers.project_id + WHERE + notifications_notifypolicy.user_id = {for_user_id} + AND notifications_notifypolicy.notify_level != {none_notify_level} + """ + sql = sql.format( + for_user_id=for_user.id, + none_notify_level=NotifyLevel.none, + project_content_type_id=ContentType.objects.get(app_label="projects", model="project").id) + return sql + + +def _build_liked_sql_for_projects(for_user): + sql = """ + SELECT projects_project.id AS id, null::integer AS ref, 'project'::text AS type, + tags, likes_like.object_id AS object_id, projects_project.id AS project, + slug, projects_project.name, null::text AS subject, + likes_like.created_date, + coalesce(watchers, 0) AS total_watchers, projects_project.total_fans AS total_fans, + null::integer AS assigned_to, null::text as status, null::text as status_color + FROM likes_like + INNER JOIN projects_project + ON (projects_project.id = likes_like.object_id) + LEFT JOIN (SELECT project_id, count(*) watchers + FROM notifications_notifypolicy + WHERE notifications_notifypolicy.notify_level != {none_notify_level} + GROUP BY project_id + ) type_watchers + ON projects_project.id = type_watchers.project_id + WHERE likes_like.user_id = {for_user_id} AND {project_content_type_id} = likes_like.content_type_id + """ + sql = sql.format( + for_user_id=for_user.id, + none_notify_level=NotifyLevel.none, + project_content_type_id=ContentType.objects.get(app_label="projects", model="project").id) + + return sql + + +def _build_sql_for_type(for_user, type, table_name, action_table, ref_column="ref", + project_column="project_id", assigned_to_column="assigned_to_id", + slug_column="slug", subject_column="subject"): + sql = """ + SELECT {table_name}.id AS id, {ref_column} AS ref, '{type}' AS type, + tags, {action_table}.object_id AS object_id, {table_name}.{project_column} AS project, + {slug_column} AS slug, null AS name, {subject_column} AS subject, + {action_table}.created_date, + coalesce(watchers, 0) AS total_watchers, null::integer AS total_fans, coalesce(votes_votes.count, 0) AS total_voters, + {assigned_to_column} AS assigned_to, projects_{type}status.name as status, projects_{type}status.color as status_color + FROM {action_table} + INNER JOIN django_content_type + ON ({action_table}.content_type_id = django_content_type.id AND django_content_type.model = '{type}') + INNER JOIN {table_name} + ON ({table_name}.id = {action_table}.object_id) + INNER JOIN projects_{type}status + ON (projects_{type}status.id = {table_name}.status_id) + LEFT JOIN (SELECT object_id, content_type_id, count(*) watchers FROM notifications_watched GROUP BY object_id, content_type_id) type_watchers + ON {table_name}.id = type_watchers.object_id AND django_content_type.id = type_watchers.content_type_id + LEFT JOIN votes_votes + ON ({table_name}.id = votes_votes.object_id AND django_content_type.id = votes_votes.content_type_id) + WHERE {action_table}.user_id = {for_user_id} + """ + sql = sql.format(for_user_id=for_user.id, type=type, table_name=table_name, + action_table=action_table, ref_column=ref_column, + project_column=project_column, assigned_to_column=assigned_to_column, + slug_column=slug_column, subject_column=subject_column) + + return sql + + +def get_watched_list(for_user, from_user, type=None, q=None): + filters_sql = "" + + if type: + filters_sql += " AND type = %(type)s " + + if q: + filters_sql += """ AND ( + to_tsvector('simple', coalesce(subject,'') || ' ' ||coalesce(entities.name,'') || ' ' ||coalesce(to_char(ref, '999'),'')) @@ to_tsquery('simple', %(q)s) + ) + """ + + sql = """ + -- BEGIN Basic info: we need to mix info from different tables and denormalize it + SELECT entities.*, + projects_project.name as project_name, projects_project.description as description, projects_project.slug as project_slug, projects_project.is_private as project_is_private, + projects_project.blocked_code as project_blocked_code, projects_project.tags_colors, projects_project.logo, + users_user.id as assigned_to_id, + row_to_json(users_user) as assigned_to_extra_info + + FROM ( + {epics_sql} + UNION + {userstories_sql} + UNION + {tasks_sql} + UNION + {issues_sql} + UNION + {projects_sql} + ) as entities + -- END Basic info + + -- BEGIN Project info + LEFT JOIN projects_project + ON (entities.project = projects_project.id) + -- END Project info + + -- BEGIN Assigned to user info + LEFT JOIN users_user + ON (assigned_to = users_user.id) + -- END Assigned to user info + + -- BEGIN Permissions checking + LEFT JOIN projects_membership + -- Here we check the memberbships from the user requesting the info + ON (projects_membership.user_id = {from_user_id} AND projects_membership.project_id = entities.project) + + LEFT JOIN users_role + ON (entities.project = users_role.project_id AND users_role.id = projects_membership.role_id) + + WHERE + -- public project + ( + projects_project.is_private = false + OR( + -- private project where the view_ permission is included in the user role for that project or in the anon permissions + projects_project.is_private = true + AND( + (entities.type = 'issue' AND 'view_issues' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'task' AND 'view_tasks' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'userstory' AND 'view_us' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'project' AND 'view_project' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'epic' AND 'view_epic' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + ) + )) + -- END Permissions checking + {filters_sql} + + ORDER BY entities.created_date DESC; + """ + + from_user_id = -1 + if not from_user.is_anonymous: + from_user_id = from_user.id + + sql = sql.format( + for_user_id=for_user.id, + from_user_id=from_user_id, + filters_sql=filters_sql, + userstories_sql=_build_sql_for_type(for_user, "userstory", "userstories_userstory", "notifications_watched", slug_column="null"), + tasks_sql=_build_sql_for_type(for_user, "task", "tasks_task", "notifications_watched", slug_column="null"), + issues_sql=_build_sql_for_type(for_user, "issue", "issues_issue", "notifications_watched", slug_column="null"), + epics_sql=_build_sql_for_type(for_user, "epic", "epics_epic", "notifications_watched", slug_column="null"), + projects_sql=_build_watched_sql_for_projects(for_user)) + + cursor = connection.cursor() + params = { + "type": type, + "q": to_tsquery(q) if q is not None else "" + } + cursor.execute(sql, params) + + desc = cursor.description + return [ + dict(zip([col[0] for col in desc], row)) + for row in cursor.fetchall() + ] + + +def get_liked_list(for_user, from_user, type=None, q=None): + filters_sql = "" + + if type: + filters_sql += " AND type = %(type)s " + + if q: + filters_sql += """ AND ( + to_tsvector('simple', coalesce(subject,'') || ' ' ||coalesce(entities.name,'') || ' ' ||coalesce(to_char(ref, '999'),'')) @@ to_tsquery('simple', %(q)s) + ) + """ + + sql = """ + -- BEGIN Basic info: we need to mix info from different tables and denormalize it + SELECT entities.*, + projects_project.name as project_name, projects_project.description as description, projects_project.slug as project_slug, projects_project.is_private as project_is_private, + projects_project.blocked_code as project_blocked_code, projects_project.tags_colors, projects_project.logo, + users_user.id as assigned_to_id, + row_to_json(users_user) as assigned_to_extra_info + FROM ( + {projects_sql} + ) as entities + -- END Basic info + + -- BEGIN Project info + LEFT JOIN projects_project + ON (entities.project = projects_project.id) + -- END Project info + + -- BEGIN Assigned to user info + LEFT JOIN users_user + ON (assigned_to = users_user.id) + -- END Assigned to user info + + -- BEGIN Permissions checking + LEFT JOIN projects_membership + -- Here we check the memberbships from the user requesting the info + ON (projects_membership.user_id = {from_user_id} AND projects_membership.project_id = entities.project) + + LEFT JOIN users_role + ON (entities.project = users_role.project_id AND users_role.id = projects_membership.role_id) + + WHERE + -- public project + ( + projects_project.is_private = false + OR( + -- private project where the view_ permission is included in the user role for that project or in the anon permissions + projects_project.is_private = true + AND( + 'view_project' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions)) + ) + )) + -- END Permissions checking + {filters_sql} + + ORDER BY entities.created_date DESC; + """ + + from_user_id = -1 + if not from_user.is_anonymous: + from_user_id = from_user.id + + sql = sql.format( + for_user_id=for_user.id, + from_user_id=from_user_id, + filters_sql=filters_sql, + projects_sql=_build_liked_sql_for_projects(for_user)) + + cursor = connection.cursor() + params = { + "type": type, + "q": to_tsquery(q) if q is not None else "" + } + cursor.execute(sql, params) + + desc = cursor.description + return [ + dict(zip([col[0] for col in desc], row)) + for row in cursor.fetchall() + ] + + +def get_voted_list(for_user, from_user, type=None, q=None): + filters_sql = "" + + if type: + filters_sql += " AND type = %(type)s " + + if q: + filters_sql += """ AND ( + to_tsvector('simple', coalesce(subject,'') || ' ' ||coalesce(entities.name,'') || ' ' ||coalesce(to_char(ref, '999'),'')) @@ to_tsquery('simple', %(q)s) + ) + """ + + sql = """ + -- BEGIN Basic info: we need to mix info from different tables and denormalize it + SELECT entities.*, + projects_project.name as project_name, projects_project.description as description, projects_project.slug as project_slug, projects_project.is_private as project_is_private, + projects_project.blocked_code as project_blocked_code, projects_project.tags_colors, projects_project.logo, + users_user.id as assigned_to_id, + row_to_json(users_user) as assigned_to_extra_info + FROM ( + {epics_sql} + UNION + {userstories_sql} + UNION + {tasks_sql} + UNION + {issues_sql} + ) as entities + -- END Basic info + + -- BEGIN Project info + LEFT JOIN projects_project + ON (entities.project = projects_project.id) + -- END Project info + + -- BEGIN Assigned to user info + LEFT JOIN users_user + ON (assigned_to = users_user.id) + -- END Assigned to user info + + -- BEGIN Permissions checking + LEFT JOIN projects_membership + -- Here we check the memberbships from the user requesting the info + ON (projects_membership.user_id = {from_user_id} AND projects_membership.project_id = entities.project) + + LEFT JOIN users_role + ON (entities.project = users_role.project_id AND users_role.id = projects_membership.role_id) + + WHERE + -- public project + ( + projects_project.is_private = false + OR( + -- private project where the view_ permission is included in the user role for that project or in the anon permissions + projects_project.is_private = true + AND( + (entities.type = 'issue' AND 'view_issues' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'task' AND 'view_tasks' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'userstory' AND 'view_us' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'epic' AND 'view_epic' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + ) + )) + -- END Permissions checking + {filters_sql} + + ORDER BY entities.created_date DESC; + """ + + from_user_id = -1 + if not from_user.is_anonymous: + from_user_id = from_user.id + + sql = sql.format( + for_user_id=for_user.id, + from_user_id=from_user_id, + filters_sql=filters_sql, + userstories_sql=_build_sql_for_type(for_user, "userstory", "userstories_userstory", "votes_vote", slug_column="null"), + tasks_sql=_build_sql_for_type(for_user, "task", "tasks_task", "votes_vote", slug_column="null"), + issues_sql=_build_sql_for_type(for_user, "issue", "issues_issue", "votes_vote", slug_column="null"), + epics_sql=_build_sql_for_type(for_user, "epic", "epics_epic", "votes_vote", slug_column="null")) + + cursor = connection.cursor() + params = { + "type": type, + "q": to_tsquery(q) if q is not None else "" + } + cursor.execute(sql, params) + + desc = cursor.description + return [ + dict(zip([col[0] for col in desc], row)) + for row in cursor.fetchall() + ] + + +def render_profile(user, outfile): + csv_data = StringIO() + fieldnames = ["username", "email", "full_name", "bio"] + + writer = csv.DictWriter(csv_data, fieldnames=fieldnames) + writer.writeheader() + + user_data = {} + for fieldname in fieldnames: + user_data[fieldname] = getattr(user, fieldname, '') + + writer.writerow(user_data) + + outfile.write(csv_data.getvalue().encode()) + + +def export_profile(user): + filename = "{}-{}".format(user.username, uuid.uuid4().hex) + + csv_path = "exports/{}/{}.csv".format(user.pk, filename) + zip_path = "exports/{}/{}.zip".format(user.pk, filename) + + with default_storage.open(csv_path, mode="wb") as output: + render_profile(user, output) + output.close() + + zf = zipfile.ZipFile(default_storage.path(zip_path), "w", zipfile.ZIP_DEFLATED) + + zf.write(default_storage.path(csv_path), "{}-profile.csv".format(filename)) + os.remove(default_storage.path(csv_path)) + + if user.photo: + _, file_extension = os.path.splitext(default_storage.path(user.photo.name)) + zf.write(default_storage.path(user.photo.name), + "{}-photo{}".format(filename, file_extension)) + + return default_storage.url(zip_path) diff --git a/taiga/users/signals.py b/taiga/users/signals.py new file mode 100644 index 000000000..407b1af1e --- /dev/null +++ b/taiga/users/signals.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import django.dispatch + + +user_change_email = django.dispatch.Signal() # providing_args=["user", "old_email", "new_email"] +user_cancel_account = django.dispatch.Signal() # providing_args=["user", "request_data"] diff --git a/taiga/users/static/img/user-noimage.png b/taiga/users/static/img/user-noimage.png new file mode 100644 index 000000000..d779bbd4d Binary files /dev/null and b/taiga/users/static/img/user-noimage.png differ diff --git a/taiga/users/templates/emails/change_email-body-html.jinja b/taiga/users/templates/emails/change_email-body-html.jinja new file mode 100644 index 000000000..889b8f904 --- /dev/null +++ b/taiga/users/templates/emails/change_email-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), full_name=user.get_full_name(), url=resolve_front_url("change-email", user.email_token) %} +

Change your email

+

Hello {{ full_name }},
please confirm your email

+ Confirm email +

You can ignore this message if you did not request.

+

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/users/templates/emails/change_email-body-text.jinja b/taiga/users/templates/emails/change_email-body-text.jinja new file mode 100644 index 000000000..e5da5461e --- /dev/null +++ b/taiga/users/templates/emails/change_email-body-text.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), full_name=user.get_full_name(), url=resolve_front_url('change-email', user.email_token) %} +Hello {{ full_name }}, please confirm your email + +{{ url }} + +You can ignore this message if you did not request. + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/users/templates/emails/change_email-subject.jinja b/taiga/users/templates/emails/change_email-subject.jinja new file mode 100644 index 000000000..db60e0a99 --- /dev/null +++ b/taiga/users/templates/emails/change_email-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{{ _("[Taiga] Change email") }} diff --git a/taiga/users/templates/emails/password_recovery-body-html.jinja b/taiga/users/templates/emails/password_recovery-body-html.jinja new file mode 100644 index 000000000..7cd89a3bf --- /dev/null +++ b/taiga/users/templates/emails/password_recovery-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), full_name=user.get_full_name(), url=resolve_front_url("change-password", user.token) %} +

Recover your password

+

Hello {{ full_name }},
you asked to recover your password

+ Recover your password +

You can ignore this message if you did not request.

+

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/users/templates/emails/password_recovery-body-text.jinja b/taiga/users/templates/emails/password_recovery-body-text.jinja new file mode 100644 index 000000000..06a101362 --- /dev/null +++ b/taiga/users/templates/emails/password_recovery-body-text.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), full_name=user.get_full_name(), url=resolve_front_url('change-password', user.token) %} +Hello {{ full_name }}, you asked to recover your password + +{{ url }} + +You can ignore this message if you did not request. + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/users/templates/emails/password_recovery-subject.jinja b/taiga/users/templates/emails/password_recovery-subject.jinja new file mode 100644 index 000000000..4f008799f --- /dev/null +++ b/taiga/users/templates/emails/password_recovery-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{{ _("[Taiga] Password recovery") }} diff --git a/taiga/users/templates/emails/registered_user-body-html.jinja b/taiga/users/templates/emails/registered_user-body-html.jinja new file mode 100644 index 000000000..f402ec6dd --- /dev/null +++ b/taiga/users/templates/emails/registered_user-body-html.jinja @@ -0,0 +1,39 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/hero-body-html.jinja" %} + +{% block body %} + + + + {% endtrans %} + +
+ {% if not user.verified_email %} + {% trans url=resolve_front_url("verify-email", user.email_token) %} +

Please confirm your email

+ Confirm email + {% endtrans %} + {% endif %} + {% trans signature=sr("signature"), product_name=sr("product_name"), support_email=sr("support.email") %} +

Thank you for registering in {{ product_name }}

+

We're thrilled you've joined our growing community of professionals revolutionizing their way of working and hope you enjoy it.

+

Have a question? Give us a nudge if you ever need a helping hand, we're at {{ support_email }}.

+

{{ signature }}

+
+{% endblock %} + +{% block footer %} + {{ super() }} +
+
+ {% trans url=resolve_front_url('cancel-account', cancel_token) %} + You may remove your account from this service clicking here + {% endtrans %} +{% endblock %} diff --git a/taiga/users/templates/emails/registered_user-body-text.jinja b/taiga/users/templates/emails/registered_user-body-text.jinja new file mode 100644 index 000000000..e7282e485 --- /dev/null +++ b/taiga/users/templates/emails/registered_user-body-text.jinja @@ -0,0 +1,28 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans product_name=sr("product_name") %} +Thank you for registering in {{ product_name }} + +We're thrilled you've joined our growing community of professionals revolutionizing their way of working and hope you enjoy it. +{% endtrans %} + +{% if not user.verified_email %} +{% trans url=resolve_front_url("verify-email", user.email_token) %} +Please confirm your email: {{ url }} +{% endtrans %} +{% endif %} + +{% trans signature=sr("signature"), support_email=sr("support.email") %} +Have a question? Give us a nudge if you ever need a helping hand, we're at {{ support_email }}. +-- +{{ signature }} +{% endtrans %} +{% trans url=resolve_front_url('cancel-account', cancel_token) %} +You may remove your account from this service: {{ url }} +{% endtrans %} diff --git a/taiga/users/templates/emails/registered_user-subject.jinja b/taiga/users/templates/emails/registered_user-subject.jinja new file mode 100644 index 000000000..2b2c59336 --- /dev/null +++ b/taiga/users/templates/emails/registered_user-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{{ _("You've been Taigatized!") }} diff --git a/taiga/users/templates/emails/send_verification-body-html.jinja b/taiga/users/templates/emails/send_verification-body-html.jinja new file mode 100644 index 000000000..9c0f12ef2 --- /dev/null +++ b/taiga/users/templates/emails/send_verification-body-html.jinja @@ -0,0 +1,19 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans signature=sr("signature"), full_name=user.get_full_name(), url=resolve_front_url("verify-email", user.email_token) %} +

Verify your email

+

Hello {{ full_name }},
please verify your email

+ Verify email +

You can ignore this message if you did not request.

+

{{ signature }}

+ {% endtrans %} +{% endblock %} diff --git a/taiga/users/templates/emails/send_verification-body-text.jinja b/taiga/users/templates/emails/send_verification-body-text.jinja new file mode 100644 index 000000000..6c9d56ad9 --- /dev/null +++ b/taiga/users/templates/emails/send_verification-body-text.jinja @@ -0,0 +1,18 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{% trans signature=sr("signature"), full_name=user.get_full_name(), url=resolve_front_url('verify-email', user.email_token) %} +Hello {{ full_name }}, please verify your email + +{{ url }} + +You can ignore this message if you did not request. + +--- +{{ signature }} +{% endtrans %} diff --git a/taiga/users/templates/emails/send_verification-subject.jinja b/taiga/users/templates/emails/send_verification-subject.jinja new file mode 100644 index 000000000..7b881877d --- /dev/null +++ b/taiga/users/templates/emails/send_verification-subject.jinja @@ -0,0 +1,9 @@ +{# +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Copyright (c) 2021-present Kaleidos INC +#} + +{{ _("[Taiga] Verify email") }} diff --git a/taiga/users/throttling.py b/taiga/users/throttling.py new file mode 100644 index 000000000..0f486acf7 --- /dev/null +++ b/taiga/users/throttling.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import throttling + + +class UserDetailRateThrottle(throttling.GlobalThrottlingMixin, throttling.ThrottleByActionMixin, throttling.SimpleRateThrottle): + scope = "user-detail" + throttled_actions = ["by_username", "retrieve"] + + +class UserUpdateRateThrottle(throttling.ThrottleByActionMixin, throttling.UserRateThrottle): + scope = "user-update" + throttled_actions = ["update", "partial_update"] diff --git a/taiga/users/utils.py b/taiga/users/utils.py new file mode 100644 index 000000000..2a70c9447 --- /dev/null +++ b/taiga/users/utils.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +def attach_roles(queryset, as_field="roles_attr"): + """Attach roles to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach the roles as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT ARRAY( + SELECT DISTINCT(users_role.name) + FROM projects_membership + INNER JOIN users_role ON users_role.id = projects_membership.role_id + WHERE projects_membership.user_id = {tbl}.id + ORDER BY users_role.name) + """ + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_extra_info(queryset, user=None): + queryset = attach_roles(queryset) + return queryset diff --git a/taiga/users/validators.py b/taiga/users/validators.py new file mode 100644 index 000000000..d20279842 --- /dev/null +++ b/taiga/users/validators.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import bleach + +from django.core import validators as core_validators +from django.utils.translation import gettext_lazy 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 .models import User, Role + +import re + + +###################################################### +# User +###################################################### + +class UserValidator(validators.ModelValidator): + full_name = serializers.CharField(max_length=36) + + class Meta: + model = User + fields = ("username", "full_name", "color", "bio", "lang", + "theme", "timezone", "is_active") + + def validate_username(self, attrs, source): + value = attrs[source] + validator = core_validators.RegexValidator(re.compile(r'^[\w.-]+$'), _("invalid username"), + _("invalid")) + + try: + validator(value) + except ValidationError: + raise ValidationError(_("Required. 255 characters or fewer. Letters, " + "numbers and /./-/_ characters'")) + + if (self.object and + self.object.username != value and + User.objects.filter(username=value).exists()): + raise ValidationError(_("Invalid username. Try with a different one.")) + + return attrs + + def validate_full_name(self, attrs, source): + value = attrs[source] + if value != bleach.clean(value): + raise ValidationError(_("Invalid full name")) + + if re.search(r"http[s]?:", value): + raise ValidationError(_("Invalid full name")) + + return attrs + + +class UserAdminValidator(UserValidator): + class Meta: + model = User + # IMPORTANT: Maintain the UserSerializer Meta up to date + # with this info (including here the email) + fields = ("username", "full_name", "color", "bio", "lang", + "theme", "timezone", "is_active", "email", "read_new_terms") + + def validate_read_new_terms(self, attrs, source): + value = attrs[source] + if not value: + raise ValidationError( + _("Read new terms has to be true'")) + + return attrs + + +class RecoveryValidator(validators.Validator): + token = serializers.CharField(required=True, max_length=200) + password = serializers.CharField(required=True, min_length=6) + + +class ChangeEmailValidator(validators.Validator): + email_token = serializers.CharField(max_length=200) + + +class CancelAccountValidator(validators.Validator): + cancel_token = serializers.CharField() + + +###################################################### +# Role +###################################################### + +class RoleValidator(validators.ModelValidator): + permissions = PgArrayField(required=False) + + class Meta: + model = Role + fields = ('id', 'name', 'permissions', 'computable', 'project', 'order') + i18n_fields = ("name",) + + +class ProjectRoleValidator(validators.ModelValidator): + class Meta: + model = Role + fields = ('id', 'name', 'slug', 'order', 'computable') diff --git a/taiga/userstorage/__init__.py b/taiga/userstorage/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/userstorage/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/userstorage/api.py b/taiga/userstorage/api.py new file mode 100644 index 000000000..6727e971f --- /dev/null +++ b/taiga/userstorage/api.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils.translation import gettext as _ + +from taiga.base.api import ModelCrudViewSet +from taiga.base import exceptions as exc + +from . import models +from . import filters +from . import serializers +from . import validators +from . import permissions + + +class StorageEntriesViewSet(ModelCrudViewSet): + model = models.StorageEntry + filter_backends = (filters.StorageEntriesFilterBackend,) + serializer_class = serializers.StorageEntrySerializer + validator_class = validators.StorageEntryValidator + permission_classes = [permissions.StorageEntriesPermission] + lookup_field = "key" + + def get_queryset(self): + if self.request.user.is_anonymous: + return self.model.objects.none() + return self.request.user.storage_entries.all() + + def pre_save(self, obj): + if self.request.user.is_authenticated: + obj.owner = self.request.user + + def create(self, *args, **kwargs): + key = self.request.DATA.get("key", None) + if (key and self.request.user.is_authenticated and + self.request.user.storage_entries.filter(key=key).exists()): + raise exc.BadRequest( + _("Duplicate key value violates unique constraint. " + "Key '{}' already exists.").format(key) + ) + return super().create(*args, **kwargs) diff --git a/taiga/userstorage/filters.py b/taiga/userstorage/filters.py new file mode 100644 index 000000000..bdf364145 --- /dev/null +++ b/taiga/userstorage/filters.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base import filters + + +class StorageEntriesFilterBackend(filters.FilterBackend): + def filter_queryset(self, request, queryset, view): + queryset = super().filter_queryset(request, queryset, view) + + query_params = {} + + if "keys" in request.QUERY_PARAMS: + field_data = request.QUERY_PARAMS["keys"] + query_params["key__in"] = field_data.split(",") + + if query_params: + queryset = queryset.filter(**query_params) + + return queryset diff --git a/taiga/userstorage/migrations/0001_initial.py b/taiga/userstorage/migrations/0001_initial.py new file mode 100644 index 000000000..9c62a060a --- /dev/null +++ b/taiga/userstorage/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.base.db.models.fields +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='StorageEntry', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), + ('created_date', models.DateTimeField(auto_now_add=True, verbose_name='created date')), + ('modified_date', models.DateTimeField(verbose_name='modified date', auto_now=True)), + ('key', models.CharField(max_length=255, verbose_name='key')), + ('value', taiga.base.db.models.fields.JSONField(verbose_name='value', blank=True, default=None, null=True)), + ('owner', models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name='owner', related_name='storage_entries', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'storages entries', + 'verbose_name': 'storage entry', + 'ordering': ['owner', 'key'], + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='storageentry', + unique_together=set([('owner', 'key')]), + ), + ] diff --git a/taiga/userstorage/migrations/0002_fix_json_field_not_null.py b/taiga/userstorage/migrations/0002_fix_json_field_not_null.py new file mode 100644 index 000000000..6caf14dc5 --- /dev/null +++ b/taiga/userstorage/migrations/0002_fix_json_field_not_null.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from taiga.base.db.models.fields import JSONField + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstorage', '0001_initial'), + ] + + operations = [ + migrations.RunSQL( + sql='ALTER TABLE userstorage_storageentry ALTER COLUMN value DROP NOT NULL;', + ), + ] diff --git a/taiga/userstorage/migrations/0003_json_to_jsonb.py b/taiga/userstorage/migrations/0003_json_to_jsonb.py new file mode 100644 index 000000000..062258d29 --- /dev/null +++ b/taiga/userstorage/migrations/0003_json_to_jsonb.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.2 on 2016-10-26 11:35 +from __future__ import unicode_literals + +from django.db import migrations +from django.contrib.postgres.fields import JSONField + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstorage', '0002_fix_json_field_not_null'), + ] + + operations = [ + migrations.RunSQL( + """ + ALTER TABLE "{table_name}" + ALTER COLUMN "{column_name}" + TYPE jsonb + USING regexp_replace("{column_name}"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """.format( + table_name="userstorage_storageentry", + column_name="value", + ), + reverse_sql=migrations.RunSQL.noop + ), + ] diff --git a/taiga/userstorage/migrations/__init__.py b/taiga/userstorage/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/userstorage/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/userstorage/models.py b/taiga/userstorage/models.py new file mode 100644 index 000000000..a807edef2 --- /dev/null +++ b/taiga/userstorage/models.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from taiga.base.db.models.fields import JSONField + + +class StorageEntry(models.Model): + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=False, + null=False, + related_name="storage_entries", + verbose_name=_("owner"), + on_delete=models.CASCADE, + ) + created_date = models.DateTimeField(auto_now_add=True, null=False, blank=False, + verbose_name=_("created date")) + modified_date = models.DateTimeField(auto_now=True, null=False, blank=False, + verbose_name=_("modified date")) + key = models.CharField(max_length=255, null=False, blank=False, verbose_name=_("key")) + value = JSONField(blank=True, default=None, null=True, verbose_name=_("value")) + + class Meta: + verbose_name = "storage entry" + verbose_name_plural = "storages entries" + unique_together = ("owner", "key") + ordering = ["owner", "key"] diff --git a/taiga/userstorage/permissions.py b/taiga/userstorage/permissions.py new file mode 100644 index 000000000..2f8058878 --- /dev/null +++ b/taiga/userstorage/permissions.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import TaigaResourcePermission, IsAuthenticated, DenyAll + + +class StorageEntriesPermission(TaigaResourcePermission): + enough_perms = IsAuthenticated() + global_perms = DenyAll() diff --git a/taiga/userstorage/serializers.py b/taiga/userstorage/serializers.py new file mode 100644 index 000000000..0c41f57bf --- /dev/null +++ b/taiga/userstorage/serializers.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import serializers +from taiga.base.fields import Field + + +class StorageEntrySerializer(serializers.LightSerializer): + key = Field() + value = Field() + created_date = Field() + modified_date = Field() diff --git a/taiga/userstorage/validators.py b/taiga/userstorage/validators.py new file mode 100644 index 000000000..c15d15405 --- /dev/null +++ b/taiga/userstorage/validators.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api import validators + +from . import models + + +class StorageEntryValidator(validators.ModelValidator): + class Meta: + model = models.StorageEntry + fields = ("key", "value") diff --git a/taiga/webhooks/__init__.py b/taiga/webhooks/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/webhooks/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/webhooks/api.py b/taiga/webhooks/api.py new file mode 100644 index 000000000..c784e6c4b --- /dev/null +++ b/taiga/webhooks/api.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.utils import timezone +from django.utils.translation import gettext as _ + +from taiga.base import filters +from taiga.base import response +from taiga.base import exceptions as exc +from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelListViewSet +from taiga.base.api.mixins import BlockedByProjectMixin + +from taiga.base.decorators import detail_route + +from . import models +from . import serializers +from . import validators +from . import permissions +from . import tasks + + +class WebhookViewSet(BlockedByProjectMixin, ModelCrudViewSet): + model = models.Webhook + serializer_class = serializers.WebhookSerializer + validator_class = validators.WebhookValidator + permission_classes = (permissions.WebhookPermission,) + filter_backends = (filters.IsProjectAdminFilterBackend,) + filter_fields = ("project",) + + @detail_route(methods=["POST"]) + def test(self, request, pk=None): + webhook = self.get_object() + self.check_permissions(request, 'test', webhook) + self.pre_conditions_blocked(webhook) + + webhooklog = tasks.test_webhook(webhook.id, webhook.url, webhook.key, request.user, timezone.now()) + log = serializers.WebhookLogSerializer(webhooklog) + + return response.Ok(log.data) + + +class WebhookLogViewSet(ModelListViewSet): + model = models.WebhookLog + serializer_class = serializers.WebhookLogSerializer + permission_classes = (permissions.WebhookLogPermission,) + filter_backends = (filters.IsProjectAdminFromWebhookLogFilterBackend,) + filter_fields = ("webhook",) + + @detail_route(methods=["POST"]) + def resend(self, request, pk=None): + webhooklog = self.get_object() + self.check_permissions(request, 'resend', webhooklog) + webhook = webhooklog.webhook + if webhook.project.blocked_code is not None: + raise exc.Blocked(_("Blocked element")) + + webhooklog = tasks.resend_webhook(webhook.id, webhook.url, webhook.key, + webhooklog.request_data) + + log = serializers.WebhookLogSerializer(webhooklog) + + return response.Ok(log.data) diff --git a/taiga/webhooks/apps.py b/taiga/webhooks/apps.py new file mode 100644 index 000000000..5c8f6dd01 --- /dev/null +++ b/taiga/webhooks/apps.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.apps import apps +from django.apps import AppConfig +from django.db.models import signals + + +def connect_webhooks_signals(): + from . import signal_handlers as handlers + signals.post_save.connect(handlers.on_new_history_entry, + sender=apps.get_model("history", "HistoryEntry"), + dispatch_uid="webhooks") + + +def disconnect_webhooks_signals(): + signals.post_save.disconnect(sender=apps.get_model("history", "HistoryEntry"), dispatch_uid="webhooks") + + +class WebhooksAppConfig(AppConfig): + name = "taiga.webhooks" + verbose_name = "Webhooks App Config" + + def ready(self): + connect_webhooks_signals() diff --git a/taiga/webhooks/migrations/0001_initial.py b/taiga/webhooks/migrations/0001_initial.py new file mode 100644 index 000000000..3899b087a --- /dev/null +++ b/taiga/webhooks/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0015_auto_20141230_1212'), + ] + + operations = [ + migrations.CreateModel( + name='Webhook', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), + ('url', models.URLField(verbose_name='URL')), + ('key', models.TextField(verbose_name='secret key')), + ('project', models.ForeignKey(related_name='webhooks', to='projects.Project', on_delete=models.CASCADE)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='WebhookLog', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), + ('url', models.URLField(verbose_name='URL')), + ('status', models.IntegerField(verbose_name='Status code')), + ('request_data', taiga.base.db.models.fields.JSONField(verbose_name='Request data')), + ('response_data', models.TextField(verbose_name='Response data')), + ('webhook', models.ForeignKey(related_name='logs', to='webhooks.Webhook', on_delete=models.CASCADE)), + ], + options={ + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/webhooks/migrations/0002_webhook_name.py b/taiga/webhooks/migrations/0002_webhook_name.py new file mode 100644 index 000000000..c6db6a10a --- /dev/null +++ b/taiga/webhooks/migrations/0002_webhook_name.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='name', + field=models.CharField(max_length=250, default='webhook', verbose_name='name'), + preserve_default=False, + ), + ] diff --git a/taiga/webhooks/migrations/0003_auto_20150122_1021.py b/taiga/webhooks/migrations/0003_auto_20150122_1021.py new file mode 100644 index 000000000..4ffa9506a --- /dev/null +++ b/taiga/webhooks/migrations/0003_auto_20150122_1021.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import datetime +import taiga.base.db.models.fields + +from django.utils import timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0002_webhook_name'), + ] + + operations = [ + migrations.AddField( + model_name='webhooklog', + name='created', + field=models.DateTimeField( + default=datetime.datetime(2015, 1, 22, 10, 21, 17, 188643, timezone.get_default_timezone()), + auto_now_add=True), + preserve_default=False, + ), + migrations.AddField( + model_name='webhooklog', + name='duration', + field=models.FloatField(default=0, verbose_name='Duration'), + preserve_default=True, + ), + migrations.AddField( + model_name='webhooklog', + name='request_headers', + field=taiga.base.db.models.fields.JSONField(default=dict, verbose_name='Request headers'), + preserve_default=True, + ), + migrations.AddField( + model_name='webhooklog', + name='response_headers', + field=taiga.base.db.models.fields.JSONField(default=dict, verbose_name='Response headers'), + preserve_default=True, + ), + ] diff --git a/taiga/webhooks/migrations/0004_auto_20150202_0834.py b/taiga/webhooks/migrations/0004_auto_20150202_0834.py new file mode 100644 index 000000000..35d13a9c9 --- /dev/null +++ b/taiga/webhooks/migrations/0004_auto_20150202_0834.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0003_auto_20150122_1021'), + ] + + operations = [ + migrations.AlterModelOptions( + name='webhook', + options={'ordering': ['name', '-id']}, + ), + migrations.AlterModelOptions( + name='webhooklog', + options={'ordering': ['-created', '-id']}, + ), + ] diff --git a/taiga/webhooks/migrations/0005_auto_20150505_1639.py b/taiga/webhooks/migrations/0005_auto_20150505_1639.py new file mode 100644 index 000000000..0b1a6435b --- /dev/null +++ b/taiga/webhooks/migrations/0005_auto_20150505_1639.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from __future__ import unicode_literals + +from django.db import models, migrations +import taiga.base.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0004_auto_20150202_0834'), + ] + + operations = [ + migrations.AlterField( + model_name='webhooklog', + name='duration', + field=models.FloatField(verbose_name='duration', default=0), + preserve_default=True, + ), + migrations.AlterField( + model_name='webhooklog', + name='request_data', + field=taiga.base.db.models.fields.JSONField(verbose_name='request data'), + preserve_default=True, + ), + migrations.AlterField( + model_name='webhooklog', + name='request_headers', + field=taiga.base.db.models.fields.JSONField(verbose_name='request headers', default=dict), + preserve_default=True, + ), + migrations.AlterField( + model_name='webhooklog', + name='response_data', + field=models.TextField(verbose_name='response data'), + preserve_default=True, + ), + migrations.AlterField( + model_name='webhooklog', + name='response_headers', + field=taiga.base.db.models.fields.JSONField(verbose_name='response headers', default=dict), + preserve_default=True, + ), + migrations.AlterField( + model_name='webhooklog', + name='status', + field=models.IntegerField(verbose_name='status code'), + preserve_default=True, + ), + ] diff --git a/taiga/webhooks/migrations/0006_json_to_jsonb.py b/taiga/webhooks/migrations/0006_json_to_jsonb.py new file mode 100644 index 000000000..c6338dda8 --- /dev/null +++ b/taiga/webhooks/migrations/0006_json_to_jsonb.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +# Generated by Django 1.10.2 on 2016-10-26 11:35 +from __future__ import unicode_literals + +from django.db import migrations +from django.contrib.postgres.fields import JSONField + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0005_auto_20150505_1639'), + ] + + operations = [ + migrations.RunSQL( + """ + ALTER TABLE "webhooks_webhooklog" + ALTER COLUMN "request_headers" + TYPE jsonb + USING regexp_replace("request_headers"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "request_data" + TYPE jsonb + USING regexp_replace("request_data"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb, + + ALTER COLUMN "response_headers" + TYPE jsonb + USING regexp_replace("response_headers"::text, '[\\\\]+u0000', '\\\\\\\\u0000', 'g')::jsonb; + """, + reverse_sql=migrations.RunSQL.noop + ), + ] diff --git a/taiga/webhooks/migrations/__init__.py b/taiga/webhooks/migrations/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/taiga/webhooks/migrations/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/taiga/webhooks/models.py b/taiga/webhooks/models.py new file mode 100644 index 000000000..735ef5041 --- /dev/null +++ b/taiga/webhooks/models.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from taiga.base.db.models.fields import JSONField + + +class Webhook(models.Model): + project = models.ForeignKey( + "projects.Project", + null=False, + blank=False, + related_name="webhooks", + on_delete=models.CASCADE, + ) + name = models.CharField(max_length=250, null=False, blank=False, + verbose_name=_("name")) + url = models.URLField(null=False, blank=False, verbose_name=_("URL")) + key = models.TextField(null=False, blank=False, verbose_name=_("secret key")) + + class Meta: + ordering = ['name', '-id'] + + +class WebhookLog(models.Model): + webhook = models.ForeignKey( + Webhook, + null=False, + blank=False, + related_name="logs", + on_delete=models.CASCADE, + ) + url = models.URLField(null=False, blank=False, verbose_name=_("URL")) + status = models.IntegerField(null=False, blank=False, verbose_name=_("status code")) + request_data = JSONField(null=False, blank=False, verbose_name=_("request data")) + request_headers = JSONField(null=False, blank=False, verbose_name=_("request headers"), default=dict) + response_data = models.TextField(null=False, blank=False, verbose_name=_("response data")) + response_headers = JSONField(null=False, blank=False, verbose_name=_("response headers"), default=dict) + duration = models.FloatField(null=False, blank=False, verbose_name=_("duration"), default=0) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created', '-id'] diff --git a/taiga/webhooks/permissions.py b/taiga/webhooks/permissions.py new file mode 100644 index 000000000..71a47af93 --- /dev/null +++ b/taiga/webhooks/permissions.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (TaigaResourcePermission, IsProjectAdmin, + AllowAny, PermissionComponent) + +from taiga.permissions.services import is_project_admin + + +class IsWebhookProjectAdmin(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return is_project_admin(request.user, obj.webhook.project) + + +class WebhookPermission(TaigaResourcePermission): + retrieve_perms = IsProjectAdmin() + create_perms = IsProjectAdmin() + update_perms = IsProjectAdmin() + partial_update_perms = IsProjectAdmin() + destroy_perms = IsProjectAdmin() + list_perms = AllowAny() + test_perms = IsProjectAdmin() + + +class WebhookLogPermission(TaigaResourcePermission): + retrieve_perms = IsWebhookProjectAdmin() + list_perms = AllowAny() + resend_perms = IsWebhookProjectAdmin() diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py new file mode 100644 index 000000000..01d0c0253 --- /dev/null +++ b/taiga/webhooks/serializers.py @@ -0,0 +1,532 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core.exceptions import ObjectDoesNotExist + +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField +from taiga.front.templatetags.functions import resolve as resolve_front_url + +from taiga.projects.services import get_logo_big_thumbnail_url + +from taiga.users.services import get_user_photo_url +from taiga.users.gravatar import get_user_gravatar_id + +######################################################################## +# WebHooks +######################################################################## + + +class WebhookSerializer(serializers.LightSerializer): + id = Field() + project = Field(attr="project_id") + name = Field() + url = Field() + key = Field() + logs_counter = MethodField() + + def get_logs_counter(self, obj): + return obj.logs.count() + + +class WebhookLogSerializer(serializers.LightSerializer): + id = Field() + webhook = Field(attr="webhook_id") + url = Field() + status = Field() + request_data = Field() + request_headers = Field() + response_data = Field() + response_headers = Field() + duration = Field() + created = Field() + + +######################################################################## +# User +######################################################################## + +class UserSerializer(serializers.LightSerializer): + id = Field(attr="pk") + permalink = MethodField() + username = MethodField() + full_name = MethodField() + photo = MethodField() + gravatar_id = MethodField() + + def get_permalink(self, obj): + return resolve_front_url("user", obj.username) + + def get_username(self, obj): + return obj.get_username() + + def get_full_name(self, obj): + return obj.get_full_name() + + def get_photo(self, obj): + return get_user_photo_url(obj) + + def get_gravatar_id(self, obj): + return get_user_gravatar_id(obj) + + def to_value(self, instance): + if instance is None: + return None + + return super().to_value(instance) + + +######################################################################## +# Project +######################################################################## + +class ProjectSerializer(serializers.LightSerializer): + id = Field(attr="pk") + permalink = MethodField() + name = MethodField() + logo_big_url = MethodField() + + def get_permalink(self, obj): + return resolve_front_url("project", obj.slug) + + def get_name(self, obj): + return obj.name + + def get_logo_big_url(self, obj): + return get_logo_big_thumbnail_url(obj) + + +######################################################################## +# History Serializer +######################################################################## + +class HistoryDiffField(Field): + def to_value(self, value): + # Tip: 'value' is the object returned by + # taiga.projects.history.models.HistoryEntry.values_diff() + + ret = {} + for key, val in value.items(): + if key in ["attachments", "custom_attributes", "description_diff"]: + ret[key] = val + elif key == "points": + ret[key] = {k: {"from": v[0], "to": v[1]} for k, v in val.items()} + else: + ret[key] = {"from": val[0], "to": val[1]} + + return ret + + +class HistoryEntrySerializer(serializers.LightSerializer): + comment = Field() + comment_html = Field() + delete_comment_date = Field() + comment_versions = Field() + edit_comment_date = Field() + diff = HistoryDiffField(attr="values_diff") + + +######################################################################## +# _Misc_ +######################################################################## + +class CustomAttributesValuesWebhookSerializerMixin(serializers.LightSerializer): + custom_attributes_values = MethodField() + + def custom_attributes_queryset(self, project): + raise NotImplementedError() + + def get_custom_attributes_values(self, obj): + if not obj: + return None + + def _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values): + ret = {} + for attr in custom_attributes: + value = values.get(str(attr["id"]), None) + if value is not None: + ret[attr["name"]] = value + + return ret + + try: + values = obj.custom_attributes_values.attributes_values + custom_attributes = self.custom_attributes_queryset(obj.project).values('id', 'name') + + return _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values) + except ObjectDoesNotExist: + return None + + +class RolePointsSerializer(serializers.LightSerializer): + role = MethodField() + name = MethodField() + value = MethodField() + + def get_role(self, obj): + return obj.role.name + + def get_name(self, obj): + return obj.points.name + + def get_value(self, obj): + return obj.points.value + + +class EpicStatusSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + slug = MethodField() + color = MethodField() + is_closed = MethodField() + + def get_name(self, obj): + return obj.name + + def get_slug(self, obj): + return obj.slug + + def get_color(self, obj): + return obj.color + + def get_is_closed(self, obj): + return obj.is_closed + + +class UserStoryStatusSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + slug = MethodField() + color = MethodField() + is_closed = MethodField() + is_archived = MethodField() + + def get_name(self, obj): + return obj.name + + def get_slug(self, obj): + return obj.slug + + def get_color(self, obj): + return obj.color + + def get_is_closed(self, obj): + return obj.is_closed + + def get_is_archived(self, obj): + return obj.is_archived + + +class TaskStatusSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + slug = MethodField() + color = MethodField() + is_closed = MethodField() + + def get_name(self, obj): + return obj.name + + def get_slug(self, obj): + return obj.slug + + def get_color(self, obj): + return obj.color + + def get_is_closed(self, obj): + return obj.is_closed + + +class IssueStatusSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + slug = MethodField() + color = MethodField() + is_closed = MethodField() + + def get_name(self, obj): + return obj.name + + def get_slug(self, obj): + return obj.slug + + def get_color(self, obj): + return obj.color + + def get_is_closed(self, obj): + return obj.is_closed + + +class IssueTypeSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + color = MethodField() + + def get_name(self, obj): + return obj.name + + def get_color(self, obj): + return obj.color + + +class PrioritySerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + color = MethodField() + + def get_name(self, obj): + return obj.name + + def get_color(self, obj): + return obj.color + + +class SeveritySerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + color = MethodField() + + def get_name(self, obj): + return obj.name + + def get_color(self, obj): + return obj.color + + +######################################################################## +# Milestone +######################################################################## + +class MilestoneSerializer(serializers.LightSerializer): + id = Field() + name = Field() + slug = Field() + estimated_start = Field() + estimated_finish = Field() + created_date = Field() + modified_date = Field() + closed = Field() + disponibility = Field() + permalink = MethodField() + project = ProjectSerializer() + owner = UserSerializer() + + def get_permalink(self, obj): + return resolve_front_url("taskboard", obj.project.slug, obj.slug) + + def to_value(self, instance): + if instance is None: + return None + + return super().to_value(instance) + + +######################################################################## +# User Story +######################################################################## + +class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + project = ProjectSerializer() + is_closed = Field() + created_date = Field() + modified_date = Field() + finish_date = Field() + due_date = Field() + due_date_reason = Field() + subject = Field() + client_requirement = Field() + team_requirement = Field() + generated_from_issue = Field(attr="generated_from_issue_id") + generated_from_task = Field(attr="generated_from_task_id") + from_task_ref = Field() + external_reference = Field() + tribe_gig = Field() + watchers = MethodField() + is_blocked = Field() + blocked_note = Field() + description = Field() + tags = Field() + permalink = MethodField() + owner = UserSerializer() + assigned_to = UserSerializer() + assigned_users = MethodField() + points = MethodField() + status = UserStoryStatusSerializer() + milestone = MilestoneSerializer() + + def get_permalink(self, obj): + return resolve_front_url("userstory", obj.project.slug, obj.ref) + + def custom_attributes_queryset(self, project): + return project.userstorycustomattributes.all() + + def get_assigned_users(self, obj): + """Get the assigned of an object. + + :return: User queryset object representing the assigned users + """ + return [user.id for user in obj.assigned_users.all()] + + def get_watchers(self, obj): + return list(obj.get_watchers().values_list("id", flat=True)) + + def get_points(self, obj): + return RolePointsSerializer(obj.role_points.all(), many=True).data + + +######################################################################## +# Task +######################################################################## + +class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + created_date = Field() + modified_date = Field() + finished_date = Field() + due_date = Field() + due_date_reason = Field() + subject = Field() + us_order = Field() + taskboard_order = Field() + is_iocaine = Field() + external_reference = Field() + watchers = MethodField() + is_blocked = Field() + blocked_note = Field() + description = Field() + tags = Field() + permalink = MethodField() + project = ProjectSerializer() + owner = UserSerializer() + assigned_to = UserSerializer() + status = TaskStatusSerializer() + user_story = UserStorySerializer() + milestone = MilestoneSerializer() + promoted_to = MethodField() + + def get_permalink(self, obj): + return resolve_front_url("task", obj.project.slug, obj.ref) + + def custom_attributes_queryset(self, project): + return project.taskcustomattributes.all() + + def get_watchers(self, obj): + return list(obj.get_watchers().values_list("id", flat=True)) + + def get_promoted_to(self, obj): + return list(obj.generated_user_stories.values_list("id", flat=True)) + + +######################################################################## +# Issue +######################################################################## + +class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + created_date = Field() + modified_date = Field() + finished_date = Field() + due_date = Field() + due_date_reason = Field() + subject = Field() + external_reference = Field() + watchers = MethodField() + description = Field() + tags = Field() + permalink = MethodField() + project = ProjectSerializer() + milestone = MilestoneSerializer() + owner = UserSerializer() + assigned_to = UserSerializer() + status = IssueStatusSerializer() + type = IssueTypeSerializer() + priority = PrioritySerializer() + severity = SeveritySerializer() + promoted_to = MethodField() + + def get_permalink(self, obj): + return resolve_front_url("issue", obj.project.slug, obj.ref) + + def custom_attributes_queryset(self, project): + return project.issuecustomattributes.all() + + def get_watchers(self, obj): + return list(obj.get_watchers().values_list("id", flat=True)) + + def get_promoted_to(self, obj): + return list(obj.generated_user_stories.values_list("id", flat=True)) + + +######################################################################## +# Wiki Page +######################################################################## + +class WikiPageSerializer(serializers.LightSerializer): + id = Field() + slug = Field() + content = Field() + created_date = Field() + modified_date = Field() + permalink = MethodField() + project = ProjectSerializer() + owner = UserSerializer() + last_modifier = UserSerializer() + + def get_permalink(self, obj): + return resolve_front_url("wiki", obj.project.slug, obj.slug) + + +######################################################################## +# Epic +######################################################################## + +class EpicSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + created_date = Field() + modified_date = Field() + subject = Field() + watchers = MethodField() + description = Field() + tags = Field() + permalink = MethodField() + project = ProjectSerializer() + owner = UserSerializer() + assigned_to = UserSerializer() + status = EpicStatusSerializer() + epics_order = Field() + color = Field() + client_requirement = Field() + team_requirement = Field() + client_requirement = Field() + team_requirement = Field() + + def get_permalink(self, obj): + return resolve_front_url("epic", obj.project.slug, obj.ref) + + def custom_attributes_queryset(self, project): + return project.epiccustomattributes.all() + + def get_watchers(self, obj): + return list(obj.get_watchers().values_list("id", flat=True)) + + +class EpicRelatedUserStorySerializer(serializers.LightSerializer): + id = Field() + user_story = MethodField() + epic = MethodField() + order = Field() + + def get_user_story(self, obj): + return UserStorySerializer(obj.user_story).data + + def get_epic(self, obj): + return EpicSerializer(obj.epic).data diff --git a/taiga/webhooks/signal_handlers.py b/taiga/webhooks/signal_handlers.py new file mode 100644 index 000000000..68417d182 --- /dev/null +++ b/taiga/webhooks/signal_handlers.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db import connection +from django.conf import settings +from django.utils import timezone + +from taiga.projects.history import services as history_service +from taiga.projects.history.choices import HistoryType + +from . import tasks + + +def _get_project_webhooks(project): + webhooks = [] + for webhook in project.webhooks.all(): + webhooks.append({ + "id": webhook.pk, + "url": webhook.url, + "key": webhook.key, + }) + return webhooks + + +def on_new_history_entry(sender, instance, created, **kwargs): + if not settings.WEBHOOKS_ENABLED: + return None + + if instance.is_hidden: + return None + + model = history_service.get_model_from_key(instance.key) + pk = history_service.get_pk_from_key(instance.key) + try: + obj = model.objects.get(pk=pk) + except model.DoesNotExist: + # Catch simultaneous DELETE request + return None + + webhooks = _get_project_webhooks(obj.project) + + if instance.type == HistoryType.create: + task = tasks.create_webhook + extra_args = [] + elif instance.type == HistoryType.change: + task = tasks.change_webhook + extra_args = [instance] + elif instance.type == HistoryType.delete: + task = tasks.delete_webhook + extra_args = [] + + by = instance.owner + date = timezone.now() + + webhooks_args = [] + for webhook in webhooks: + args = [webhook["id"], webhook["url"], webhook["key"], by, date, obj] + extra_args + webhooks_args.append(args) + + connection.on_commit(lambda: _execute_task(task, webhooks_args)) + + +def _execute_task(task, webhooks_args): + for webhook_args in webhooks_args: + + if settings.CELERY_ENABLED: + task.delay(*webhook_args) + else: + task(*webhook_args) diff --git a/taiga/webhooks/tasks.py b/taiga/webhooks/tasks.py new file mode 100644 index 000000000..c2034a393 --- /dev/null +++ b/taiga/webhooks/tasks.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import hmac +import hashlib +import requests +from requests.exceptions import RequestException + +from django.conf import settings + +from taiga.base.api.renderers import UnicodeJSONRenderer +from taiga.base.utils import json, urls +from taiga.base.utils.db import get_typename_for_model_instance +from taiga.celery import app + +from .serializers import (EpicSerializer, EpicRelatedUserStorySerializer, + UserStorySerializer, IssueSerializer, TaskSerializer, + WikiPageSerializer, MilestoneSerializer, + HistoryEntrySerializer, UserSerializer) + +from .models import WebhookLog + + +def _serialize(obj): + content_type = get_typename_for_model_instance(obj) + if content_type == "epics.epic": + return EpicSerializer(obj).data + elif content_type == "epics.relateduserstory": + return EpicRelatedUserStorySerializer(obj).data + elif content_type == "userstories.userstory": + return UserStorySerializer(obj).data + elif content_type == "issues.issue": + return IssueSerializer(obj).data + elif content_type == "tasks.task": + return TaskSerializer(obj).data + elif content_type == "wiki.wikipage": + return WikiPageSerializer(obj).data + elif content_type == "milestones.milestone": + return MilestoneSerializer(obj).data + elif content_type == "history.historyentry": + return HistoryEntrySerializer(obj).data + + +def _get_type(obj): + content_type = get_typename_for_model_instance(obj) + return content_type.split(".")[1] + + +def _generate_signature(data, key): + mac = hmac.new(key.encode("utf-8"), msg=data, digestmod=hashlib.sha1) + return mac.hexdigest() + + +def _remove_leftover_webhooklogs(webhook_id): + # Only the last ten webhook logs traces are required + # so remove the leftover + ids = (WebhookLog.objects.filter(webhook_id=webhook_id) + .order_by("-id") + .values_list('id', flat=True)[10:]) + WebhookLog.objects.filter(id__in=ids).delete() + + +def _send_request(webhook_id, url, key, data): + serialized_data = UnicodeJSONRenderer().render(data) + signature = _generate_signature(serialized_data, key) + headers = { + "X-TAIGA-WEBHOOK-SIGNATURE": signature, # For backward compatibility + "X-Hub-Signature": "sha1={}".format(signature), + "Content-Type": "application/json" + } + + if not settings.WEBHOOKS_ALLOW_PRIVATE_ADDRESS: + try: + urls.validate_private_url(url) + except (urls.IpAddresValueError, urls.HostnameException) as e: + # Error validating url + webhook_log = WebhookLog.objects.create(webhook_id=webhook_id, + url=url, + status=0, + request_data=data, + request_headers=dict(), + response_data="error-in-request: {}".format(str(e)), + response_headers={}, + duration=0) + _remove_leftover_webhooklogs(webhook_id) + + return webhook_log + + request = requests.Request('POST', url, data=serialized_data, headers=headers) + prepared_request = request.prepare() + + with requests.Session() as session: + response = None + try: + response = session.send(prepared_request, allow_redirects=settings.WEBHOOKS_ALLOW_REDIRECTS) + + if not settings.WEBHOOKS_ALLOW_REDIRECTS and response.status_code in [301, 302, 303, 307, 308]: + raise RequestException("Redirects are not allowed") + + except RequestException as e: + # Error sending the webhook + webhook_log = WebhookLog.objects.create(webhook_id=webhook_id, + url=url, + status=response.status_code if response else 0, + request_data=data, + request_headers=dict(prepared_request.headers), + response_data="error-in-request: {}".format(str(e)), + response_headers={}, + duration=0) + else: + # Webhook was sent successfully + + # response.content can be a not valid json so we encapsulate it + response_data = json.dumps({"content": response.text}) + webhook_log = WebhookLog.objects.create(webhook_id=webhook_id, url=url, + status=response.status_code, + request_data=data, + request_headers=dict(prepared_request.headers), + response_data=response_data, + response_headers=dict(response.headers), + duration=response.elapsed.total_seconds()) + finally: + _remove_leftover_webhooklogs(webhook_id) + + return webhook_log + + +@app.task +def create_webhook(webhook_id, url, key, by, date, obj): + data = {} + data['action'] = "create" + data['type'] = _get_type(obj) + data['by'] = UserSerializer(by).data + data['date'] = date + data['data'] = _serialize(obj) + + return _send_request(webhook_id, url, key, data) + + +@app.task +def delete_webhook(webhook_id, url, key, by, date, obj): + data = {} + data['action'] = "delete" + data['type'] = _get_type(obj) + data['by'] = UserSerializer(by).data + data['date'] = date + data['data'] = _serialize(obj) + + return _send_request(webhook_id, url, key, data) + + +@app.task +def change_webhook(webhook_id, url, key, by, date, obj, change): + data = {} + data['action'] = "change" + data['type'] = _get_type(obj) + data['by'] = UserSerializer(by).data + data['date'] = date + data['data'] = _serialize(obj) + data['change'] = _serialize(change) + + return _send_request(webhook_id, url, key, data) + + +@app.task +def resend_webhook(webhook_id, url, key, data): + return _send_request(webhook_id, url, key, data) + + +@app.task +def test_webhook(webhook_id, url, key, by, date): + data = {} + data['action'] = "test" + data['type'] = "test" + data['by'] = UserSerializer(by).data + data['date'] = date + data['data'] = {"test": "test"} + return _send_request(webhook_id, url, key, data) diff --git a/taiga/webhooks/validators.py b/taiga/webhooks/validators.py new file mode 100644 index 000000000..6a0fbd98b --- /dev/null +++ b/taiga/webhooks/validators.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import ipaddress +from urllib.parse import urlparse + +from django.conf import settings +from django.utils.translation import gettext as _ + +from taiga.base.api import validators +from taiga.base.exceptions import ValidationError + +from .models import Webhook + + +class WebhookValidator(validators.ModelValidator): + class Meta: + model = Webhook + + def validate_url(self, attrs, source): + if not settings.WEBHOOKS_ALLOW_PRIVATE_ADDRESS: + host = urlparse(attrs[source]).hostname + try: + ipa = ipaddress.ip_address(host) + except ValueError: + return attrs + if ipa.is_private: + raise ValidationError(_("Not allowed IP Address")) + return attrs + return attrs diff --git a/taiga/wsgi.py b/taiga/wsgi.py new file mode 100644 index 000000000..509040c51 --- /dev/null +++ b/taiga/wsgi.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + + +""" +WSGI config for taiga project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It should expose a module-level variable +named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover +this application via the ``WSGI_APPLICATION`` setting. + +Usually you will have the standard Django WSGI application here, but it also +might make sense to replace the whole Django WSGI application with a custom one +that later delegates to the Django one. For example, you could introduce WSGI +middleware here, or combine a Django application with an application of another +framework. + +""" +import os + +# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks +# if running multiple sites in the same mod_wsgi process. To fix this, use +# mod_wsgi daemon mode with each site in its own daemon process, or use +# os.environ["DJANGO_SETTINGS_MODULE"] = "taiga.settings" +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.common") + +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +# Apply WSGI middleware here. +# from helloworld.wsgi import HelloWorldApplication +# application = HelloWorldApplication(application) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/tests/config.py b/tests/config.py new file mode 100644 index 000000000..ec26ef0b8 --- /dev/null +++ b/tests/config.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from settings.common import * # noqa, pylint: disable=unused-wildcard-import + +DEBUG = True + +ENABLE_TELEMETRY = False + +SECRET_KEY = "not very secret in tests" + +TEMPLATES[0]["OPTIONS"]['context_processors'] += "django.template.context_processors.debug" + +CELERY_ENABLED = False + +MEDIA_ROOT = "/tmp" + +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" +INSTALLED_APPS = INSTALLED_APPS + [ + "tests", +] + +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { + "anon-write": None, + "anon-read": None, + "user-write": None, + "user-read": None, + "import-mode": None, + "import-dump-mode": None, + "create-memberships": None, + "login-fail": None, + "register-success": None, + "user-detail": None, + "user-update": None, +} + + +IMPORTERS['github']['active'] = True +IMPORTERS['jira']['active'] = True +IMPORTERS['asana']['active'] = True +IMPORTERS['trello']['active'] = True + +FRONT_SITEMAP_ENABLED = True +FRONT_SITEMAP_CACHE_TIMEOUT = 1 # In second +FRONT_SITEMAP_PAGE_SIZE = 100 + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'taiga', + 'USER': 'taiga', + 'PASSWORD': 'taiga', + 'HOST': 'localhost', + 'PORT': '5432', + } +} + +# This is only for GitHubActions +if os.getenv('GITHUB_WORKFLOW'): + DATABASES = { + 'default': { + "ENGINE": "django.db.backends.postgresql", + 'NAME': 'taiga', + 'USER': 'postgres', + 'PASSWORD': 'postgres', + 'HOST': 'localhost', + 'PORT': '5432', + } + } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..3b1775837 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import django +from .fixtures import * + + +def pytest_addoption(parser): + parser.addoption("--runslow", action="store_true", help="run slow tests") + + +def pytest_runtest_setup(item): + if "slow" in item.keywords and not item.config.getoption("--runslow"): + pytest.skip("need --runslow option to run") + + +def pytest_configure(config): + django.setup() + from taiga.celery import app + app.conf.task_always_eager = True diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 000000000..c64df66ba --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,784 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid +import threading +from datetime import date, timedelta + +from django.conf import settings + +from .utils import DUMMY_BMP_DATA + +import factory + +from taiga.permissions.choices import MEMBERS_PERMISSIONS + + + +class Factory(factory.django.DjangoModelFactory): + class Meta: + strategy = factory.CREATE_STRATEGY + model = None + abstract = True + + _SEQUENCE = 1 + _SEQUENCE_LOCK = threading.Lock() + + @classmethod + def _setup_next_sequence(cls): + with cls._SEQUENCE_LOCK: + cls._SEQUENCE += 1 + return cls._SEQUENCE + + +class ProjectTemplateFactory(Factory): + class Meta: + strategy = factory.CREATE_STRATEGY + model = "projects.ProjectTemplate" + django_get_or_create = ("slug",) + + name = "Template name" + slug = settings.DEFAULT_PROJECT_TEMPLATE + description = factory.Sequence(lambda n: "Description {}".format(n)) + + epic_statuses = [] + us_statuses = [] + us_duedates = [] + points = [] + task_statuses = [] + task_duedates = [] + issue_statuses = [] + issue_types = [] + issue_duedates = [] + priorities = [] + severities = [] + roles = [] + epic_custom_attributes = [] + us_custom_attributes = [] + task_custom_attributes = [] + issue_custom_attributes = [] + default_owner_role = "tester" + + +class ProjectFactory(Factory): + class Meta: + model = "projects.Project" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Project {}".format(n)) + slug = factory.Sequence(lambda n: "project-{}-slug".format(n)) + logo = factory.django.FileField(data=DUMMY_BMP_DATA) + + description = "Project description" + owner = factory.SubFactory("tests.factories.UserFactory") + creation_template = factory.SubFactory("tests.factories.ProjectTemplateFactory") + + +class ProjectModulesConfigFactory(Factory): + class Meta: + model = "projects.ProjectModulesConfig" + strategy = factory.CREATE_STRATEGY + + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class RoleFactory(Factory): + class Meta: + model = "users.Role" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Role {}".format(n)) + slug = factory.Sequence(lambda n: "test-role-{}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class PointsFactory(Factory): + class Meta: + model = "projects.Points" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Points {}".format(n)) + value = 2 + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class SwimlaneFactory(Factory): + class Meta: + model = "projects.Swimlane" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Swimlane {}".format(n)) + order = factory.Sequence(lambda n: n) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class RolePointsFactory(Factory): + class Meta: + model = "userstories.RolePoints" + strategy = factory.CREATE_STRATEGY + + user_story = factory.SubFactory("tests.factories.UserStoryFactory") + role = factory.SubFactory("tests.factories.RoleFactory") + points = factory.SubFactory("tests.factories.PointsFactory") + + +class EpicAttachmentFactory(Factory): + project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + content_object = factory.SubFactory("tests.factories.EpicFactory") + attached_file = factory.django.FileField(data=b"File contents") + name = factory.Sequence(lambda n: "Epic Attachment {}".format(n)) + + class Meta: + model = "attachments.Attachment" + strategy = factory.CREATE_STRATEGY + + +class UserStoryAttachmentFactory(Factory): + project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + content_object = factory.SubFactory("tests.factories.UserStoryFactory") + attached_file = factory.django.FileField(data=b"File contents") + name = factory.Sequence(lambda n: "User Story Attachment {}".format(n)) + + class Meta: + model = "attachments.Attachment" + strategy = factory.CREATE_STRATEGY + + +class TaskAttachmentFactory(Factory): + project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + content_object = factory.SubFactory("tests.factories.TaskFactory") + attached_file = factory.django.FileField(data=b"File contents") + name = factory.Sequence(lambda n: "Task Attachment {}".format(n)) + + class Meta: + model = "attachments.Attachment" + strategy = factory.CREATE_STRATEGY + + +class IssueAttachmentFactory(Factory): + project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + content_object = factory.SubFactory("tests.factories.IssueFactory") + attached_file = factory.django.FileField(data=b"File contents") + name = factory.Sequence(lambda n: "Issue Attachment {}".format(n)) + + class Meta: + model = "attachments.Attachment" + strategy = factory.CREATE_STRATEGY + + +class WikiAttachmentFactory(Factory): + project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + content_object = factory.SubFactory("tests.factories.WikiPageFactory") + attached_file = factory.django.FileField(data=b"File contents") + name = factory.Sequence(lambda n: "Wiki Attachment {}".format(n)) + + class Meta: + model = "attachments.Attachment" + strategy = factory.CREATE_STRATEGY + + +class UserFactory(Factory): + class Meta: + model = settings.AUTH_USER_MODEL + strategy = factory.CREATE_STRATEGY + + username = factory.Sequence(lambda n: "user{}".format(n)) + email = factory.LazyAttribute(lambda obj: '%s@email.com' % obj.username) + password = factory.PostGeneration(lambda obj, *args, **kwargs: obj.set_password(obj.username)) + accepted_terms = True + read_new_terms = True + + +class MembershipFactory(Factory): + class Meta: + model = "projects.Membership" + strategy = factory.CREATE_STRATEGY + + token = factory.LazyAttribute(lambda obj: str(uuid.uuid1())) + project = factory.SubFactory("tests.factories.ProjectFactory") + role = factory.SubFactory("tests.factories.RoleFactory") + user = factory.SubFactory("tests.factories.UserFactory") + + +class InvitationFactory(Factory): + class Meta: + model = "projects.Membership" + strategy = factory.CREATE_STRATEGY + + token = factory.LazyAttribute(lambda obj: str(uuid.uuid1())) + project = factory.SubFactory("tests.factories.ProjectFactory") + role = factory.SubFactory("tests.factories.RoleFactory") + email = factory.Sequence(lambda n: "user{}@email.com".format(n)) + + +class WebhookFactory(Factory): + class Meta: + model = "webhooks.Webhook" + strategy = factory.CREATE_STRATEGY + + project = factory.SubFactory("tests.factories.ProjectFactory") + url = "http://localhost:8080/test" + key = "factory-key" + name = "Factory-name" + + +class WebhookLogFactory(Factory): + class Meta: + model = "webhooks.WebhookLog" + strategy = factory.CREATE_STRATEGY + + webhook = factory.SubFactory("tests.factories.WebhookFactory") + url = "http://localhost:8080/test" + status = "200" + request_data = {"text": "test-request-data"} + response_data = {"text": "test-response-data"} + + +class StorageEntryFactory(Factory): + class Meta: + model = "userstorage.StorageEntry" + strategy = factory.CREATE_STRATEGY + + owner = factory.SubFactory("tests.factories.UserFactory") + key = factory.Sequence(lambda n: "key-{}".format(n)) + value = factory.Sequence(lambda n: {"value": "value-{}".format(n)}) + + +class EpicFactory(Factory): + class Meta: + model = "epics.Epic" + strategy = factory.CREATE_STRATEGY + + ref = factory.Sequence(lambda n: n) + project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + subject = factory.Sequence(lambda n: "Epic {}".format(n)) + description = factory.Sequence(lambda n: "Epic {} description".format(n)) + status = factory.SubFactory("tests.factories.EpicStatusFactory") + + +class RelatedUserStory(Factory): + class Meta: + model = "epics.RelatedUserStory" + strategy = factory.CREATE_STRATEGY + + epic = factory.SubFactory("tests.factories.EpicFactory") + user_story = factory.SubFactory("tests.factories.UserStoryFactory") + + +class MilestoneFactory(Factory): + class Meta: + model = "milestones.Milestone" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Milestone {}".format(n)) + owner = factory.SubFactory("tests.factories.UserFactory") + project = factory.SubFactory("tests.factories.ProjectFactory") + estimated_start = factory.LazyAttribute(lambda o: date.today()) + estimated_finish = factory.LazyAttribute(lambda o: o.estimated_start + timedelta(days=7)) + + +class UserStoryFactory(Factory): + class Meta: + model = "userstories.UserStory" + strategy = factory.CREATE_STRATEGY + + ref = factory.Sequence(lambda n: n) + project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + subject = factory.Sequence(lambda n: "User Story {}".format(n)) + description = factory.Sequence(lambda n: "User Story {} description".format(n)) + status = factory.SubFactory("tests.factories.UserStoryStatusFactory") + milestone = factory.SubFactory("tests.factories.MilestoneFactory") + tags = factory.Faker("words") + due_date = factory.LazyAttribute(lambda o: date.today() + timedelta(days=7)) + due_date_reason = factory.Faker("words") + + @factory.post_generation + def assigned_users(self, create, users_list, **kwargs): + if not create: + return + + if users_list: + for user in users_list: + self.assigned_users.add(user) + + +class TaskFactory(Factory): + class Meta: + model = "tasks.Task" + strategy = factory.CREATE_STRATEGY + + ref = factory.Sequence(lambda n: n) + subject = factory.Sequence(lambda n: "Task {}".format(n)) + description = factory.Sequence(lambda n: "Task {} description".format(n)) + owner = factory.SubFactory("tests.factories.UserFactory") + project = factory.SubFactory("tests.factories.ProjectFactory") + status = factory.SubFactory("tests.factories.TaskStatusFactory") + milestone = factory.SubFactory("tests.factories.MilestoneFactory") + user_story = factory.SubFactory("tests.factories.UserStoryFactory") + tags = factory.Faker("words") + due_date = factory.LazyAttribute(lambda o: date.today() + timedelta(days=7)) + due_date_reason = factory.Faker("words") + + +class IssueFactory(Factory): + class Meta: + model = "issues.Issue" + strategy = factory.CREATE_STRATEGY + + ref = factory.Sequence(lambda n: n) + subject = factory.Sequence(lambda n: "Issue {}".format(n)) + description = factory.Sequence(lambda n: "Issue {} description".format(n)) + owner = factory.SubFactory("tests.factories.UserFactory") + project = factory.SubFactory("tests.factories.ProjectFactory") + status = factory.SubFactory("tests.factories.IssueStatusFactory") + severity = factory.SubFactory("tests.factories.SeverityFactory") + priority = factory.SubFactory("tests.factories.PriorityFactory") + type = factory.SubFactory("tests.factories.IssueTypeFactory") + milestone = factory.SubFactory("tests.factories.MilestoneFactory") + tags = factory.Faker("words") + due_date = factory.LazyAttribute(lambda o: date.today() + timedelta(days=7)) + due_date_reason = factory.Faker("words") + + +class WikiPageFactory(Factory): + class Meta: + model = "wiki.WikiPage" + strategy = factory.CREATE_STRATEGY + + project = factory.SubFactory("tests.factories.ProjectFactory") + owner = factory.SubFactory("tests.factories.UserFactory") + slug = factory.Sequence(lambda n: "wiki-page-{}".format(n)) + content = factory.Sequence(lambda n: "Wiki Page {} content".format(n)) + + +class WikiLinkFactory(Factory): + class Meta: + model = "wiki.WikiLink" + strategy = factory.CREATE_STRATEGY + + project = factory.SubFactory("tests.factories.ProjectFactory") + title = factory.Sequence(lambda n: "Wiki Link {} title".format(n)) + href = factory.Sequence(lambda n: "link-{}".format(n)) + order = factory.Sequence(lambda n: n) + + +class EpicStatusFactory(Factory): + class Meta: + model = "projects.EpicStatus" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Epic status {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class UserStoryStatusFactory(Factory): + class Meta: + model = "projects.UserStoryStatus" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "User Story status {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class UserStoryDueDateFactory(Factory): + class Meta: + model = "projects.UserStoryDueDate" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "User Story due date {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class TaskStatusFactory(Factory): + class Meta: + model = "projects.TaskStatus" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Task status {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class IssueStatusFactory(Factory): + class Meta: + model = "projects.IssueStatus" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Issue Status {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class SeverityFactory(Factory): + class Meta: + model = "projects.Severity" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Severity {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class PriorityFactory(Factory): + class Meta: + model = "projects.Priority" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Priority {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class IssueTypeFactory(Factory): + class Meta: + model = "projects.IssueType" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Issue Type {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class EpicCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.EpicCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Epic Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for Epic Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class UserStoryCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.UserStoryCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "UserStory Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for UserStory Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class TaskCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.TaskCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Task Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for Task Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class IssueCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.IssueCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Issue Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for Issue Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class EpicCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.EpicCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + epic = factory.SubFactory("tests.factories.EpicFactory") + + +class UserStoryCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.UserStoryCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + user_story = factory.SubFactory("tests.factories.UserStoryFactory") + + +class TaskCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.TaskCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + task = factory.SubFactory("tests.factories.TaskFactory") + + +class IssueCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.IssueCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + issue = factory.SubFactory("tests.factories.IssueFactory") + + +class LikeFactory(Factory): + class Meta: + model = "likes.Like" + strategy = factory.CREATE_STRATEGY + + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) + user = factory.SubFactory("tests.factories.UserFactory") + + +class VoteFactory(Factory): + class Meta: + model = "votes.Vote" + strategy = factory.CREATE_STRATEGY + + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) + user = factory.SubFactory("tests.factories.UserFactory") + + +class VotesFactory(Factory): + class Meta: + model = "votes.Votes" + strategy = factory.CREATE_STRATEGY + + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) + + +class WatchedFactory(Factory): + class Meta: + model = "notifications.Watched" + strategy = factory.CREATE_STRATEGY + + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) + user = factory.SubFactory("tests.factories.UserFactory") + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class ContentTypeFactory(Factory): + class Meta: + model = "contenttypes.ContentType" + strategy = factory.CREATE_STRATEGY + django_get_or_create = ("app_label", "model") + + app_label = factory.LazyAttribute(lambda obj: "issues") + model = factory.LazyAttribute(lambda obj: "Issue") + + +class AttachmentFactory(Factory): + class Meta: + model = "attachments.Attachment" + strategy = factory.CREATE_STRATEGY + + owner = factory.SubFactory("tests.factories.UserFactory") + project = factory.SubFactory("tests.factories.ProjectFactory") + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) + attached_file = factory.django.FileField(data=b"File contents") + name = factory.Sequence(lambda n: "Attachment {}".format(n)) + + +class HistoryEntryFactory(Factory): + class Meta: + model = "history.HistoryEntry" + strategy = factory.CREATE_STRATEGY + + type = 1 + + +class ApplicationFactory(Factory): + class Meta: + model = "external_apps.Application" + strategy = factory.CREATE_STRATEGY + + +class ApplicationTokenFactory(Factory): + class Meta: + model = "external_apps.ApplicationToken" + strategy = factory.CREATE_STRATEGY + + application = factory.SubFactory("tests.factories.ApplicationFactory") + user = factory.SubFactory("tests.factories.UserFactory") + +def create_issue(**kwargs): + "Create an issue and along with its dependencies." + owner = kwargs.pop("owner", None) + if owner is None: + owner = UserFactory.create() + + project = kwargs.pop("project", None) + if project is None: + project = ProjectFactory.create(owner=owner) + + defaults = { + "project": project, + "owner": owner, + "status": IssueStatusFactory.create(project=project), + "milestone": MilestoneFactory.create(project=project), + "priority": PriorityFactory.create(project=project), + "severity": SeverityFactory.create(project=project), + "type": IssueTypeFactory.create(project=project), + } + defaults.update(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) + if not owner: + owner = UserFactory.create() + + project = kwargs.pop("project", None) + 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": 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) + + +def create_membership(**kwargs): + "Create a membership along with its dependencies" + project = kwargs.pop("project", ProjectFactory()) + project.points.add(PointsFactory.create(project=project, value=None)) + + defaults = { + "project": project, + "user": UserFactory.create(), + "role": RoleFactory.create(project=project, + permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + } + defaults.update(kwargs) + + return MembershipFactory.create(**defaults) + + +def create_invitation(**kwargs): + "Create an invitation along with its dependencies" + project = kwargs.pop("project", ProjectFactory()) + project.points.add(PointsFactory.create(project=project, value=None)) + + defaults = { + "project": project, + "role": RoleFactory.create(project=project), + "email": "invited-user@email.com", + "token": "tokenvalue", + "invited_by_id": project.owner.id + } + defaults.update(kwargs) + + return MembershipFactory.create(**defaults) + + +def create_userstory(**kwargs): + """Create an user story along with its dependencies""" + owner = kwargs.pop("owner", None) + if not owner: + owner = UserFactory.create() + + project = kwargs.pop("project", None) + if project is None: + project = ProjectFactory.create(owner=owner) + project.default_points = PointsFactory.create(project=project) + + defaults = { + "project": project, + "owner": owner, + "milestone": MilestoneFactory.create(project=project, owner=owner) + } + defaults.update(kwargs) + + return UserStoryFactory(**defaults) + + +def create_epic(**kwargs): + "Create an epic along with its dependencies" + + owner = kwargs.pop("owner", None) + if not owner: + owner = UserFactory.create() + + project = kwargs.pop("project", None) + if project is None: + project = ProjectFactory.create(owner=owner) + + defaults = { + "project": project, + "owner": owner, + } + defaults.update(kwargs) + + return EpicFactory(**defaults) + + +def create_project(**kwargs): + "Create a project along with its dependencies" + defaults = {} + defaults.update(kwargs) + + ProjectTemplateFactory.create(slug=settings.DEFAULT_PROJECT_TEMPLATE) + + project = ProjectFactory.create(**defaults) + project.default_issue_status = IssueStatusFactory.create(project=project) + project.default_severity = SeverityFactory.create(project=project) + project.default_priority = PriorityFactory.create(project=project) + project.default_issue_type = IssueTypeFactory.create(project=project) + project.default_us_status = UserStoryStatusFactory.create(project=project) + project.default_task_status = TaskStatusFactory.create(project=project) + project.default_epic_status = EpicStatusFactory.create(project=project) + project.default_points = PointsFactory.create(project=project) + + project.save() + + return project + + +def create_swimlane(**kwargs): + "Create a swimlane along with its dependencies." + + project = kwargs.pop("project", None) + if project is None: + project = ProjectFactory.create() + + defaults = { + "project": project, + } + defaults.update(kwargs) + + return SwimlaneFactory.create(**defaults) + + +def create_user(**kwargs): + "Create an user along with her dependencies" + ProjectTemplateFactory.create(slug=settings.DEFAULT_PROJECT_TEMPLATE) + RoleFactory.create() + return UserFactory.create(**kwargs) diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 000000000..d5eb729a5 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest import mock +import functools + + +class Object: + pass + + +@pytest.fixture +def object(): + return Object() + + +class PartialMethodCaller: + def __init__(self, obj, **partial_params): + self.obj = obj + self.partial_params = partial_params + + def __getattr__(self, name): + return functools.partial(getattr(self.obj, name), **self.partial_params) + + +@pytest.fixture +def client(): + from django.test.client import Client + + class _Client(Client): + def login(self, user=None, backend="django.contrib.auth.backends.ModelBackend", **credentials): + if user is None: + return super().login(**credentials) + + with mock.patch('django.contrib.auth.authenticate') as authenticate: + user.backend = backend + authenticate.return_value = user + return super().login(**credentials) + + @property + def json(self): + return PartialMethodCaller(obj=self, content_type='application/json;charset="utf-8"') + + return _Client() + + +@pytest.fixture +def outbox(): + from django.core import mail + + return mail.outbox diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/tests/integration/resources_permissions/__init__.py b/tests/integration/resources_permissions/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/tests/integration/resources_permissions/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/tests/integration/resources_permissions/test_application_tokens_resources.py b/tests/integration/resources_permissions/test_application_tokens_resources.py new file mode 100644 index 000000000..202b33ae3 --- /dev/null +++ b/tests/integration/resources_permissions/test_application_tokens_resources.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.token = f.ApplicationTokenFactory(state="random-state") + m.registered_user_with_token = m.token.user + return m + + +def test_application_tokens_create(client, data): + url = reverse('application-tokens-list') + + users = [ + None, + data.registered_user, + data.registered_user_with_token + ] + + data = json.dumps({"application": data.token.application.id}) + results = helper_test_http_method(client, "post", url, data, users) + assert results == [405, 405, 405] + + +def test_applications_retrieve_token(client, data): + url=reverse('applications-token', kwargs={"pk": data.token.application.id}) + + users = [ + None, + data.registered_user, + data.registered_user_with_token + ] + + results = helper_test_http_method(client, "get", url, None, users) + assert results == [401, 200, 200] + + +def test_application_tokens_retrieve(client, data): + url = reverse('application-tokens-detail', kwargs={"pk": data.token.id}) + + users = [ + None, + data.registered_user, + data.registered_user_with_token + ] + + results = helper_test_http_method(client, "get", url, None, users) + assert results == [401, 404, 200] + + +def test_application_tokens_authorize(client, data): + url=reverse('application-tokens-authorize') + + users = [ + None, + data.registered_user, + data.registered_user_with_token + ] + + data = json.dumps({ + "application": data.token.application.id, + "state": "random-state-123123", + }) + + results = helper_test_http_method(client, "post", url, data, users) + assert results == [401, 200, 200] + + +def test_application_tokens_validate(client, data): + url=reverse('application-tokens-validate') + + users = [ + None, + data.registered_user, + data.registered_user_with_token + ] + + data = json.dumps({ + "application": data.token.application.id, + "auth_code": data.token.auth_code, + "state": data.token.state + }) + + results = helper_test_http_method(client, "post", url, data, users) + assert results == [200, 200, 200] + + +def test_application_tokens_update(client, data): + url = reverse('application-tokens-detail', kwargs={"pk": data.token.id}) + + users = [ + None, + data.registered_user, + data.registered_user_with_token + ] + + patch_data = json.dumps({"application": data.token.application.id}) + results = helper_test_http_method(client, "patch", url, patch_data, users) + assert results == [405, 405, 405] + + +def test_application_tokens_delete(client, data): + url = reverse('application-tokens-detail', kwargs={"pk": data.token.id}) + + users = [ + None, + data.registered_user, + data.registered_user_with_token + ] + + results = helper_test_http_method(client, "delete", url, None, users) + assert results == [401, 403, 204] + + +def test_application_tokens_list(client, data): + url = reverse('application-tokens-list') + + users = [ + None, + data.registered_user, + data.registered_user_with_token + ] + + results = helper_test_http_method(client, "get", url, None, users) + assert results == [401, 200, 200] diff --git a/tests/integration/resources_permissions/test_attachment_resources.py b/tests/integration/resources_permissions/test_attachment_resources.py new file mode 100644 index 000000000..70765f537 --- /dev/null +++ b/tests/integration/resources_permissions/test_attachment_resources.py @@ -0,0 +1,1081 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test.client import MULTIPART_CONTENT + +from taiga.base.utils import json + +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects import choices as project_choices +from taiga.projects.attachments.serializers import AttachmentSerializer + +from tests import factories as f +from tests.utils import helper_test_http_method +from tests.utils import helper_test_http_method_and_count +from tests.utils import disconnect_signals +from tests.utils import reconnect_signals + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + return m + + +@pytest.fixture +def data_epic(data): + m = type("Models", (object,), {}) + m.public_epic = f.EpicFactory(project=data.public_project, ref=20) + m.public_epic_attachment = f.EpicAttachmentFactory(project=data.public_project, content_object=m.public_epic) + m.private_epic1 = f.EpicFactory(project=data.private_project1, ref=21) + m.private_epic1_attachment = f.EpicAttachmentFactory(project=data.private_project1, content_object=m.private_epic1) + m.private_epic2 = f.EpicFactory(project=data.private_project2, ref=22) + m.private_epic2_attachment = f.EpicAttachmentFactory(project=data.private_project2, content_object=m.private_epic2) + m.blocked_epic = f.EpicFactory(project=data.blocked_project, ref=23) + m.blocked_epic_attachment = f.EpicAttachmentFactory(project=data.blocked_project, content_object=m.blocked_epic) + return m + + +@pytest.fixture +def data_us(data): + m = type("Models", (object,), {}) + m.public_user_story = f.UserStoryFactory(project=data.public_project, ref=1) + m.public_user_story_attachment = f.UserStoryAttachmentFactory(project=data.public_project, + content_object=m.public_user_story) + m.private_user_story1 = f.UserStoryFactory(project=data.private_project1, ref=5) + m.private_user_story1_attachment = f.UserStoryAttachmentFactory(project=data.private_project1, + content_object=m.private_user_story1) + m.private_user_story2 = f.UserStoryFactory(project=data.private_project2, ref=9) + m.private_user_story2_attachment = f.UserStoryAttachmentFactory(project=data.private_project2, + content_object=m.private_user_story2) + m.blocked_user_story = f.UserStoryFactory(project=data.blocked_project, ref=13) + m.blocked_user_story_attachment = f.UserStoryAttachmentFactory(project=data.blocked_project, + content_object=m.blocked_user_story) + return m + + +@pytest.fixture +def data_task(data): + m = type("Models", (object,), {}) + m.public_task = f.TaskFactory(project=data.public_project, ref=2) + m.public_task_attachment = f.TaskAttachmentFactory(project=data.public_project, content_object=m.public_task) + m.private_task1 = f.TaskFactory(project=data.private_project1, ref=6) + m.private_task1_attachment = f.TaskAttachmentFactory(project=data.private_project1, content_object=m.private_task1) + m.private_task2 = f.TaskFactory(project=data.private_project2, ref=10) + m.private_task2_attachment = f.TaskAttachmentFactory(project=data.private_project2, content_object=m.private_task2) + m.blocked_task = f.TaskFactory(project=data.blocked_project, ref=14) + m.blocked_task_attachment = f.TaskAttachmentFactory(project=data.blocked_project, content_object=m.blocked_task) + return m + + +@pytest.fixture +def data_issue(data): + m = type("Models", (object,), {}) + m.public_issue = f.IssueFactory(project=data.public_project, ref=3) + m.public_issue_attachment = f.IssueAttachmentFactory(project=data.public_project, content_object=m.public_issue) + m.private_issue1 = f.IssueFactory(project=data.private_project1, ref=7) + m.private_issue1_attachment = f.IssueAttachmentFactory(project=data.private_project1, content_object=m.private_issue1) + m.private_issue2 = f.IssueFactory(project=data.private_project2, ref=11) + m.private_issue2_attachment = f.IssueAttachmentFactory(project=data.private_project2, content_object=m.private_issue2) + m.blocked_issue = f.IssueFactory(project=data.blocked_project, ref=11) + m.blocked_issue_attachment = f.IssueAttachmentFactory(project=data.blocked_project, content_object=m.blocked_issue) + return m + + +@pytest.fixture +def data_wiki(data): + m = type("Models", (object,), {}) + m.public_wiki = f.WikiPageFactory(project=data.public_project, slug=4) + m.public_wiki_attachment = f.WikiAttachmentFactory(project=data.public_project, content_object=m.public_wiki) + m.private_wiki1 = f.WikiPageFactory(project=data.private_project1, slug=8) + m.private_wiki1_attachment = f.WikiAttachmentFactory(project=data.private_project1, content_object=m.private_wiki1) + m.private_wiki2 = f.WikiPageFactory(project=data.private_project2, slug=12) + m.private_wiki2_attachment = f.WikiAttachmentFactory(project=data.private_project2, content_object=m.private_wiki2) + m.blocked_wiki = f.WikiPageFactory(project=data.blocked_project, slug=1) + m.blocked_wiki_attachment = f.WikiAttachmentFactory(project=data.blocked_project, content_object=m.blocked_wiki) + return m + + +def test_epic_attachment_retrieve(client, data, data_epic): + public_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.public_epic_attachment.pk}) + private_url1 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic1_attachment.pk}) + private_url2 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic2_attachment.pk}) + blocked_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.blocked_epic_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_user_story_attachment_retrieve(client, data, data_us): + public_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.public_user_story_attachment.pk}) + private_url1 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story1_attachment.pk}) + private_url2 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story2_attachment.pk}) + blocked_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.blocked_user_story_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_attachment_retrieve(client, data, data_task): + public_url = reverse('task-attachments-detail', kwargs={"pk": data_task.public_task_attachment.pk}) + private_url1 = reverse('task-attachments-detail', kwargs={"pk": data_task.private_task1_attachment.pk}) + private_url2 = reverse('task-attachments-detail', kwargs={"pk": data_task.private_task2_attachment.pk}) + blocked_url = reverse('task-attachments-detail', kwargs={"pk": data_task.blocked_task_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_attachment_retrieve(client, data, data_issue): + public_url = reverse('issue-attachments-detail', kwargs={"pk": data_issue.public_issue_attachment.pk}) + private_url1 = reverse('issue-attachments-detail', kwargs={"pk": data_issue.private_issue1_attachment.pk}) + private_url2 = reverse('issue-attachments-detail', kwargs={"pk": data_issue.private_issue2_attachment.pk}) + blocked_url = reverse('issue-attachments-detail', kwargs={"pk": data_issue.blocked_issue_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_wiki_attachment_retrieve(client, data, data_wiki): + public_url = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.public_wiki_attachment.pk}) + private_url1 = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.private_wiki1_attachment.pk}) + private_url2 = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.private_wiki2_attachment.pk}) + blocked_url = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.blocked_wiki_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_attachment_update(client, data, data_epic): + public_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.public_epic_attachment.pk}) + private_url1 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic1_attachment.pk}) + private_url2 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic2_attachment.pk}) + blocked_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.blocked_epic_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = AttachmentSerializer(data_epic.public_epic_attachment).data + attachment_data["description"] = "test" + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'put', public_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', private_url1, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', private_url2, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', blocked_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + +def test_user_story_attachment_update(client, data, data_us): + public_url = reverse("userstory-attachments-detail", + args=[data_us.public_user_story_attachment.pk]) + private_url1 = reverse("userstory-attachments-detail", + args=[data_us.private_user_story1_attachment.pk]) + private_url2 = reverse("userstory-attachments-detail", + args=[data_us.private_user_story2_attachment.pk]) + blocked_url = reverse("userstory-attachments-detail", + args=[data_us.blocked_user_story_attachment.pk]) + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = AttachmentSerializer(data_us.public_user_story_attachment).data + attachment_data["description"] = "test" + + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, "put", public_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 400, 400] + + results = helper_test_http_method(client, "put", private_url1, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 400, 400] + + results = helper_test_http_method(client, "put", private_url2, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 400, 400] + + results = helper_test_http_method(client, "put", blocked_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 400, 400] + + +def test_task_attachment_update(client, data, data_task): + public_url = reverse('task-attachments-detail', kwargs={"pk": data_task.public_task_attachment.pk}) + private_url1 = reverse('task-attachments-detail', kwargs={"pk": data_task.private_task1_attachment.pk}) + private_url2 = reverse('task-attachments-detail', kwargs={"pk": data_task.private_task2_attachment.pk}) + blocked_url = reverse('task-attachments-detail', kwargs={"pk": data_task.blocked_task_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = AttachmentSerializer(data_task.public_task_attachment).data + attachment_data["description"] = "test" + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'put', public_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', private_url1, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', private_url2, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', blocked_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + +def test_issue_attachment_update(client, data, data_issue): + public_url = reverse('issue-attachments-detail', kwargs={"pk": data_issue.public_issue_attachment.pk}) + private_url1 = reverse('issue-attachments-detail', kwargs={"pk": data_issue.private_issue1_attachment.pk}) + private_url2 = reverse('issue-attachments-detail', kwargs={"pk": data_issue.private_issue2_attachment.pk}) + blocked_url = reverse('issue-attachments-detail', kwargs={"pk": data_issue.blocked_issue_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = AttachmentSerializer(data_issue.public_issue_attachment).data + attachment_data["description"] = "test" + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'put', public_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', private_url1, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', private_url2, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', blocked_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + +def test_wiki_attachment_update(client, data, data_wiki): + public_url = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.public_wiki_attachment.pk}) + private_url1 = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.private_wiki1_attachment.pk}) + private_url2 = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.private_wiki2_attachment.pk}) + blocked_url = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.blocked_wiki_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = AttachmentSerializer(data_wiki.public_wiki_attachment).data + attachment_data["description"] = "test" + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'put', public_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'put', private_url1, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'put', private_url2, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'put', blocked_url, attachment_data, users) + assert results == [405, 405, 405, 405, 405] + # assert results == [401, 403, 403, 200, 200] + + +def test_epic_attachment_patch(client, data, data_epic): + public_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.public_epic_attachment.pk}) + private_url1 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic1_attachment.pk}) + private_url2 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic2_attachment.pk}) + blocked_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.blocked_epic_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test"} + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_attachment_patch(client, data, data_us): + public_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.public_user_story_attachment.pk}) + private_url1 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story1_attachment.pk}) + private_url2 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story2_attachment.pk}) + blocked_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.blocked_user_story_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test"} + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_attachment_patch(client, data, data_task): + public_url = reverse('task-attachments-detail', kwargs={"pk": data_task.public_task_attachment.pk}) + private_url1 = reverse('task-attachments-detail', kwargs={"pk": data_task.private_task1_attachment.pk}) + private_url2 = reverse('task-attachments-detail', kwargs={"pk": data_task.private_task2_attachment.pk}) + blocked_url = reverse('task-attachments-detail', kwargs={"pk": data_task.blocked_task_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test"} + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_attachment_patch(client, data, data_issue): + public_url = reverse('issue-attachments-detail', kwargs={"pk": data_issue.public_issue_attachment.pk}) + private_url1 = reverse('issue-attachments-detail', kwargs={"pk": data_issue.private_issue1_attachment.pk}) + private_url2 = reverse('issue-attachments-detail', kwargs={"pk": data_issue.private_issue2_attachment.pk}) + blocked_url = reverse('issue-attachments-detail', kwargs={"pk": data_issue.blocked_issue_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test"} + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_attachment_patch(client, data, data_wiki): + public_url = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.public_wiki_attachment.pk}) + private_url1 = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.private_wiki1_attachment.pk}) + private_url2 = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.private_wiki2_attachment.pk}) + blocked_url = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.blocked_wiki_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test"} + attachment_data = json.dumps(attachment_data) + + results = helper_test_http_method(client, 'patch', public_url, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url1, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url2, attachment_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', blocked_url, attachment_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_attachment_delete(client, data, data_epic): + public_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.public_epic_attachment.pk}) + private_url1 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic1_attachment.pk}) + private_url2 = reverse('epic-attachments-detail', kwargs={"pk": data_epic.private_epic2_attachment.pk}) + blocked_url = reverse('epic-attachments-detail', kwargs={"pk": data_epic.blocked_epic_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_user_story_attachment_delete(client, data, data_us): + public_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.public_user_story_attachment.pk}) + private_url1 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story1_attachment.pk}) + private_url2 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story2_attachment.pk}) + blocked_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.blocked_user_story_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_task_attachment_delete(client, data, data_task): + public_url = reverse('task-attachments-detail', kwargs={"pk": data_task.public_task_attachment.pk}) + private_url1 = reverse('task-attachments-detail', kwargs={"pk": data_task.private_task1_attachment.pk}) + private_url2 = reverse('task-attachments-detail', kwargs={"pk": data_task.private_task2_attachment.pk}) + blocked_url = reverse('task-attachments-detail', kwargs={"pk": data_task.blocked_task_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_issue_attachment_delete(client, data, data_issue): + public_url = reverse('issue-attachments-detail', kwargs={"pk": data_issue.public_issue_attachment.pk}) + private_url1 = reverse('issue-attachments-detail', kwargs={"pk": data_issue.private_issue1_attachment.pk}) + private_url2 = reverse('issue-attachments-detail', kwargs={"pk": data_issue.private_issue2_attachment.pk}) + blocked_url = reverse('issue-attachments-detail', kwargs={"pk": data_issue.blocked_issue_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_wiki_attachment_delete(client, data, data_wiki): + public_url = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.public_wiki_attachment.pk}) + private_url1 = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.private_wiki1_attachment.pk}) + private_url2 = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.private_wiki2_attachment.pk}) + blocked_url = reverse('wiki-attachments-detail', kwargs={"pk": data_wiki.blocked_wiki_attachment.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + + results = helper_test_http_method(client, 'delete', public_url, None, [None, data.registered_user]) + assert results == [401, 403] + + results = helper_test_http_method(client, 'delete', private_url1, None, [None, data.registered_user]) + assert results == [401, 403] + + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_epic_attachment_create(client, data, data_epic): + url = reverse('epic-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test", + "object_id": data_epic.public_epic_attachment.object_id, + "project": data_epic.public_epic_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + assert results == [401, 403, 403, 201, 201] + + attachment_data = {"description": "test", + "object_id": data_epic.blocked_epic_attachment.object_id, + "project": data_epic.blocked_epic_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_attachment_create(client, data, data_us): + url = reverse('userstory-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test", + "object_id": data_us.public_user_story_attachment.object_id, + "project": data_us.public_user_story_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + assert results == [401, 403, 403, 201, 201] + + attachment_data = {"description": "test", + "object_id": data_us.blocked_user_story_attachment.object_id, + "project": data_us.blocked_user_story_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + assert results == [401, 403, 403, 451, 451] + + +def test_task_attachment_create(client, data, data_task): + url = reverse('task-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test", + "object_id": data_task.public_task_attachment.object_id, + "project": data_task.public_task_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + assert results == [401, 403, 403, 201, 201] + + attachment_data = {"description": "test", + "object_id": data_task.blocked_task_attachment.object_id, + "project": data_task.blocked_task_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_attachment_create(client, data, data_issue): + url = reverse('issue-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test", + "object_id": data_issue.public_issue_attachment.object_id, + "project": data_issue.public_issue_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + + assert results == [401, 403, 403, 201, 201] + + attachment_data = {"description": "test", + "object_id": data_issue.blocked_issue_attachment.object_id, + "project": data_issue.blocked_issue_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_attachment_create(client, data, data_wiki): + url = reverse('wiki-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + attachment_data = {"description": "test", + "object_id": data_wiki.public_wiki_attachment.object_id, + "project": data_wiki.public_wiki_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + + assert results == [401, 403, 403, 201, 201] + + attachment_data = {"description": "test", + "object_id": data_wiki.blocked_wiki_attachment.object_id, + "project": data_wiki.blocked_wiki_attachment.project_id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + _after_each_request_hook = lambda: attachment_data["attached_file"].seek(0) + + results = helper_test_http_method(client, 'post', url, attachment_data, users, + content_type=MULTIPART_CONTENT, + after_each_request=_after_each_request_hook) + + assert results == [401, 403, 403, 451, 451] + + +def test_epic_attachment_list(client, data, data_epic): + url = reverse('epic-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_count(client, 'get', url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 4), (200, 4)] + + +def test_user_story_attachment_list(client, data, data_us): + url = reverse('userstory-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_count(client, 'get', url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 4), (200, 4)] + + +def test_task_attachment_list(client, data, data_task): + url = reverse('task-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_count(client, 'get', url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 4), (200, 4)] + + +def test_issue_attachment_list(client, data, data_issue): + url = reverse('issue-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_count(client, 'get', url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 4), (200, 4)] + + +def test_wiki_attachment_list(client, data, data_wiki): + url = reverse('wiki-attachments-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_count(client, 'get', url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 4), (200, 4)] + + +def test_create_attachment_by_external_user_without_comment_permission(client): + issue = f.create_issue() + user = f.UserFactory() + + assert issue.owner != user + assert issue.project.owner != user + + url = reverse("issue-attachments-list") + + data = {"description": "test", + "object_id": issue.pk, + "project": issue.project.id, + "attached_file": SimpleUploadedFile("test.txt", b"test"), + "from_comment": True} + + client.login(user) + response = client.post(url, data) + assert response.status_code == 403 + + +def test_create_attachment_by_external_user_with_comment_permission_but_without_from_comment_flag(client): + project = f.ProjectFactory(public_permissions=['comment_issue']) + issue = f.create_issue(project=project) + + user = f.UserFactory() + + assert issue.owner != user + assert issue.project.owner != user + + url = reverse("issue-attachments-list") + + data = {"description": "test", + "object_id": issue.pk, + "project": issue.project.id, + "attached_file": SimpleUploadedFile("test.txt", b"test"), + "from_comment": False} + + client.login(user) + response = client.post(url, data) + assert response.status_code == 403 + + +def test_create_attachment_by_external_user_with_comment_permission_and_with_from_comment_flag(client): + project = f.ProjectFactory(public_permissions=['comment_issue']) + issue = f.create_issue(project=project) + + user = f.UserFactory() + + assert issue.owner != user + assert issue.project.owner != user + + url = reverse("issue-attachments-list") + + data = {"description": "test", + "object_id": issue.pk, + "project": issue.project.id, + "attached_file": SimpleUploadedFile("test.txt", b"test"), + "from_comment": True} + + client.login(user) + response = client.post(url, data) + assert response.status_code == 201 diff --git a/tests/integration/resources_permissions/test_auth_resources.py b/tests/integration/resources_permissions/test_auth_resources.py new file mode 100644 index 000000000..9429a16a1 --- /dev/null +++ b/tests/integration/resources_permissions/test_auth_resources.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json + +from tests import factories as f +from tests.utils import disconnect_signals, reconnect_signals + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +def test_auth_create(client): + url = reverse('auth-list') + + user = f.UserFactory.create() + + login_data = json.dumps({ + "type": "normal", + "username": user.username, + "password": user.username, + }) + + result = client.post(url, login_data, content_type="application/json") + assert result.status_code == 200 + + +def test_auth_refresh(client): + url = reverse('auth-list') + + user = f.UserFactory.create() + + login_data = json.dumps({ + "type": "normal", + "username": user.username, + "password": user.username, + }) + + result = client.post(url, login_data, content_type="application/json") + assert result.status_code == 200 + + url = reverse('auth-refresh') + + refresh_data = json.dumps({ + "refresh": result.data["refresh"], + }) + + result = client.post(url, refresh_data, content_type="application/json") + assert result.status_code == 200 + + +def test_auth_action_register_with_short_password(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + url = reverse('auth-register') + + register_data = json.dumps({ + "type": "public", + "username": "test", + "password": "test", + "full_name": "test", + "email": "test@test.com", + "accepted_terms": True, + }) + + result = client.post(url, register_data, content_type="application/json") + assert result.status_code == 400, result.json() + + +def test_auth_action_register(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + url = reverse('auth-register') + + register_data = json.dumps({ + "type": "public", + "username": "test", + "password": "test123", + "full_name": "test123", + "email": "test@test.com", + "accepted_terms": True, + }) + + result = client.post(url, register_data, content_type="application/json") + assert result.status_code == 201, result.json() diff --git a/tests/integration/resources_permissions/test_contact.py b/tests/integration/resources_permissions/test_contact.py new file mode 100644 index 000000000..3eae4d829 --- /dev/null +++ b/tests/integration/resources_permissions/test_contact.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from tests import factories as f +from tests.utils import helper_test_http_method + +from taiga.base.utils import json + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.user = f.UserFactory.create() + m.project = f.ProjectFactory.create(is_private=False) + f.MembershipFactory(user=m.project.owner, project=m.project, is_admin=True) + + return m + + +def test_contact_create(client, data): + url = reverse("contact-list") + users = [None, data.user] + + contact_data = json.dumps({ + "project": data.project.id, + "comment": "Testing comment" + }) + results = helper_test_http_method(client, 'post', url, contact_data, users) + assert results == [401, 201] diff --git a/tests/integration/resources_permissions/test_epics_custom_attributes_resource.py b/tests/integration/resources_permissions/test_epics_custom_attributes_resource.py new file mode 100644 index 000000000..d4f2784d1 --- /dev/null +++ b/tests/integration/resources_permissions/test_epics_custom_attributes_resource.py @@ -0,0 +1,446 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.custom_attributes import serializers +from taiga.permissions.choices import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS) + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project2, + role__permissions=[]) + + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_epic_ca = f.EpicCustomAttributeFactory(project=m.public_project) + m.private_epic_ca1 = f.EpicCustomAttributeFactory(project=m.private_project1) + m.private_epic_ca2 = f.EpicCustomAttributeFactory(project=m.private_project2) + m.blocked_epic_ca = f.EpicCustomAttributeFactory(project=m.blocked_project) + + m.public_epic = f.EpicFactory(project=m.public_project, + status__project=m.public_project) + m.private_epic1 = f.EpicFactory(project=m.private_project1, + status__project=m.private_project1) + m.private_epic2 = f.EpicFactory(project=m.private_project2, + status__project=m.private_project2) + m.blocked_epic = f.EpicFactory(project=m.blocked_project, + status__project=m.blocked_project) + + m.public_epic_cav = m.public_epic.custom_attributes_values + m.private_epic_cav1 = m.private_epic1.custom_attributes_values + m.private_epic_cav2 = m.private_epic2.custom_attributes_values + m.blocked_epic_cav = m.blocked_epic.custom_attributes_values + + return m + + +######################################################### +# Epic Custom Attribute +######################################################### + +def test_epic_custom_attribute_retrieve(client, data): + public_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.public_epic_ca.pk}) + private1_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca1.pk}) + private2_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca2.pk}) + blocked_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.blocked_epic_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_custom_attribute_create(client, data): + public_url = reverse('epic-custom-attributes-list') + private1_url = reverse('epic-custom-attributes-list') + private2_url = reverse('epic-custom-attributes-list') + blocked_url = reverse('epic-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_ca_data = {"name": "test-new", "project": data.public_project.id} + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'post', public_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + epic_ca_data = {"name": "test-new", "project": data.private_project1.id} + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'post', private1_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + epic_ca_data = {"name": "test-new", "project": data.private_project2.id} + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'post', private2_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + epic_ca_data = {"name": "test-new", "project": data.blocked_project.id} + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'post', blocked_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_custom_attribute_update(client, data): + public_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.public_epic_ca.pk}) + private1_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca1.pk}) + private2_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca2.pk}) + blocked_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.blocked_epic_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_ca_data = serializers.EpicCustomAttributeSerializer(data.public_epic_ca).data + epic_ca_data["name"] = "test" + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'put', public_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_ca_data = serializers.EpicCustomAttributeSerializer(data.private_epic_ca1).data + epic_ca_data["name"] = "test" + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'put', private1_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_ca_data = serializers.EpicCustomAttributeSerializer(data.private_epic_ca2).data + epic_ca_data["name"] = "test" + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'put', private2_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_ca_data = serializers.EpicCustomAttributeSerializer(data.blocked_epic_ca).data + epic_ca_data["name"] = "test" + epic_ca_data = json.dumps(epic_ca_data) + results = helper_test_http_method(client, 'put', private2_url, epic_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_custom_attribute_delete(client, data): + public_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.public_epic_ca.pk}) + private1_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca1.pk}) + private2_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca2.pk}) + blocked_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.blocked_epic_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + + +def test_epic_custom_attribute_list(client, data): + url = reverse('epic-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + +def test_epic_custom_attribute_patch(client, data): + public_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.public_epic_ca.pk}) + private1_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca1.pk}) + private2_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.private_epic_ca2.pk}) + blocked_url = reverse('epic-custom-attributes-detail', kwargs={"pk": data.blocked_epic_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_custom_attribute_action_bulk_update_order(client, data): + url = reverse('epic-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_epic_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_custom_attributes": [(1,2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + +######################################################### +# Epic Custom Attribute +######################################################### + + +def test_epic_custom_attributes_values_retrieve(client, data): + public_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.public_epic.pk}) + private_url1 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic1.pk}) + private_url2 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic2.pk}) + blocked_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_custom_attributes_values_update(client, data): + public_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.public_epic.pk}) + private_url1 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic1.pk}) + private_url2 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic2.pk}) + blocked_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_data = serializers.EpicCustomAttributesValuesSerializer(data.public_epic_cav).data + epic_data["attributes_values"] = {str(data.public_epic_ca.pk): "test"} + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', public_url, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = serializers.EpicCustomAttributesValuesSerializer(data.private_epic_cav1).data + epic_data["attributes_values"] = {str(data.private_epic_ca1.pk): "test"} + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url1, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = serializers.EpicCustomAttributesValuesSerializer(data.private_epic_cav2).data + epic_data["attributes_values"] = {str(data.private_epic_ca2.pk): "test"} + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url2, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = serializers.EpicCustomAttributesValuesSerializer(data.blocked_epic_cav).data + epic_data["attributes_values"] = {str(data.blocked_epic_ca.pk): "test"} + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_custom_attributes_values_patch(client, data): + public_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.public_epic.pk}) + private_url1 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic1.pk}) + private_url2 = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.private_epic2.pk}) + blocked_url = reverse('epic-custom-attributes-values-detail', kwargs={"epic_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_epic_ca.pk): "test"}, + "version": data.public_epic.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_epic_ca1.pk): "test"}, + "version": data.private_epic1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_epic_ca2.pk): "test"}, + "version": data.private_epic2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.blocked_epic_ca.pk): "test"}, + "version": data.blocked_epic.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] diff --git a/tests/integration/resources_permissions/test_epics_related_userstories_resources.py b/tests/integration/resources_permissions/test_epics_related_userstories_resources.py new file mode 100644 index 000000000..a6b3f0058 --- /dev/null +++ b/tests/integration/resources_permissions/test_epics_related_userstories_resources.py @@ -0,0 +1,395 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.epics.serializers import EpicRelatedUserStorySerializer +from taiga.projects.epics.models import Epic +from taiga.projects.epics.utils import attach_extra_info as attach_epic_extra_info +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.occ import OCCResourceMixin + +from tests import factories as f +from tests.utils import helper_test_http_method, reconnect_signals +from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_function(function): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex, + blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) + + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_epic = f.EpicFactory(project=m.public_project, + status__project=m.public_project) + m.public_epic = attach_epic_extra_info(Epic.objects.all()).get(id=m.public_epic.id) + + m.private_epic1 = f.EpicFactory(project=m.private_project1, + status__project=m.private_project1) + m.private_epic1 = attach_epic_extra_info(Epic.objects.all()).get(id=m.private_epic1.id) + + m.private_epic2 = f.EpicFactory(project=m.private_project2, + status__project=m.private_project2) + m.private_epic2 = attach_epic_extra_info(Epic.objects.all()).get(id=m.private_epic2.id) + + m.blocked_epic = f.EpicFactory(project=m.blocked_project, + status__project=m.blocked_project) + m.blocked_epic = attach_epic_extra_info(Epic.objects.all()).get(id=m.blocked_epic.id) + + + m.public_us = f.UserStoryFactory(project=m.public_project) + m.private_us1 = f.UserStoryFactory(project=m.private_project1) + m.private_us2 = f.UserStoryFactory(project=m.private_project2) + m.blocked_us = f.UserStoryFactory(project=m.blocked_project) + + m.public_related_us = f.RelatedUserStory(epic=m.public_epic, user_story=m.public_us) + m.private_related_us1 = f.RelatedUserStory(epic=m.private_epic1, user_story=m.private_us1) + m.private_related_us2 = f.RelatedUserStory(epic=m.private_epic2, user_story=m.private_us2) + m.blocked_related_us = f.RelatedUserStory(epic=m.blocked_epic, user_story=m.blocked_us) + + m.public_project.default_epic_status = m.public_epic.status + m.public_project.save() + m.private_project1.default_epic_status = m.private_epic1.status + m.private_project1.save() + m.private_project2.default_epic_status = m.private_epic2.status + m.private_project2.save() + m.blocked_project.default_epic_status = m.blocked_epic.status + m.blocked_project.save() + + return m + + +def test_epic_related_userstories_list(client, data): + url = reverse('epics-related-userstories-list', args=[data.public_epic.pk]) + response = client.get(url) + related_uss_data = json.loads(response.content.decode('utf-8')) + assert len(related_uss_data) == 1 + assert response.status_code == 200 + + client.login(data.registered_user) + + url = reverse('epics-related-userstories-list', args=[data.private_epic1.pk]) + response = client.get(url) + related_uss_data = json.loads(response.content.decode('utf-8')) + assert len(related_uss_data) == 1 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + url = reverse('epics-related-userstories-list', args=[data.private_epic2.pk]) + response = client.get(url) + related_uss_data = json.loads(response.content.decode('utf-8')) + assert len(related_uss_data) == 1 + assert response.status_code == 200 + + client.login(data.project_owner) + + url = reverse('epics-related-userstories-list', args=[data.blocked_epic.pk]) + response = client.get(url) + related_uss_data = json.loads(response.content.decode('utf-8')) + assert len(related_uss_data) == 1 + assert response.status_code == 200 + + +def test_epic_related_userstories_retrieve(client, data): + public_url = reverse('epics-related-userstories-detail', args=[data.public_epic.pk, data.public_us.pk]) + private_url1 = reverse('epics-related-userstories-detail', args=[data.private_epic1.pk, data.private_us1.pk]) + private_url2 = reverse('epics-related-userstories-detail', args=[data.private_epic2.pk, data.private_us2.pk]) + blocked_url = reverse('epics-related-userstories-detail', args=[data.blocked_epic.pk, data.blocked_us.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_related_userstories_create(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({ + "user_story": f.UserStoryFactory(project=data.public_project).id, + "epic": data.public_epic.id + }) + url = reverse('epics-related-userstories-list', args=[data.public_epic.pk]) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 400] + + create_data = json.dumps({ + "user_story": f.UserStoryFactory(project=data.private_project1).id, + "epic": data.private_epic1.id + }) + url = reverse('epics-related-userstories-list', args=[data.private_epic1.pk]) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 400] + + create_data = json.dumps({ + "user_story": f.UserStoryFactory(project=data.private_project2).id, + "epic": data.private_epic2.id + }) + url = reverse('epics-related-userstories-list', args=[data.private_epic2.pk]) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 400] + + create_data = json.dumps({ + "user_story": f.UserStoryFactory(project=data.blocked_project).id, + "epic": data.blocked_epic.id + }) + url = reverse('epics-related-userstories-list', args=[data.blocked_epic.pk]) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_related_userstories_put_update(client, data): + public_url = reverse('epics-related-userstories-detail', args=[data.public_epic.pk, data.public_us.pk]) + private_url1 = reverse('epics-related-userstories-detail', args=[data.private_epic1.pk, data.private_us1.pk]) + private_url2 = reverse('epics-related-userstories-detail', args=[data.private_epic2.pk, data.private_us2.pk]) + blocked_url = reverse('epics-related-userstories-detail', args=[data.blocked_epic.pk, data.blocked_us.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_related_us_data = EpicRelatedUserStorySerializer(data.public_related_us).data + epic_related_us_data["order"] = 33 + epic_related_us_data = json.dumps(epic_related_us_data) + results = helper_test_http_method(client, 'put', public_url, epic_related_us_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_related_us_data = EpicRelatedUserStorySerializer(data.private_related_us1).data + epic_related_us_data["order"] = 33 + epic_related_us_data = json.dumps(epic_related_us_data) + results = helper_test_http_method(client, 'put', private_url1, epic_related_us_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_related_us_data = EpicRelatedUserStorySerializer(data.private_related_us2).data + epic_related_us_data["order"] = 33 + epic_related_us_data = json.dumps(epic_related_us_data) + results = helper_test_http_method(client, 'put', private_url2, epic_related_us_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_related_us_data = EpicRelatedUserStorySerializer(data.blocked_related_us).data + epic_related_us_data["order"] = 33 + epic_related_us_data = json.dumps(epic_related_us_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_related_us_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_related_userstories_patch_update(client, data): + public_url = reverse('epics-related-userstories-detail', args=[data.public_epic.pk, data.public_us.pk]) + private_url1 = reverse('epics-related-userstories-detail', args=[data.private_epic1.pk, data.private_us1.pk]) + private_url2 = reverse('epics-related-userstories-detail', args=[data.private_epic2.pk, data.private_us2.pk]) + blocked_url = reverse('epics-related-userstories-detail', args=[data.blocked_epic.pk, data.blocked_us.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"order": 33}) + + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_related_userstories_delete(client, data): + public_url = reverse('epics-related-userstories-detail', args=[data.public_epic.pk, data.public_us.pk]) + private_url1 = reverse('epics-related-userstories-detail', args=[data.private_epic1.pk, data.private_us1.pk]) + private_url2 = reverse('epics-related-userstories-detail', args=[data.private_epic2.pk, data.private_us2.pk]) + blocked_url = reverse('epics-related-userstories-detail', args=[data.blocked_epic.pk, data.blocked_us.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_bulk_create_related_userstories(client, data): + public_url = reverse('epics-related-userstories-bulk-create', args=[data.public_epic.pk]) + private_url1 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic1.pk]) + private_url2 = reverse('epics-related-userstories-bulk-create', args=[data.private_epic2.pk]) + blocked_url = reverse('epics-related-userstories-bulk-create', args=[data.blocked_epic.pk]) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + bulk_data = json.dumps({ + "bulk_userstories": "test1\ntest2", + "project_id": data.public_project.id + }) + results = helper_test_http_method(client, 'post', public_url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_userstories": "test1\ntest2", + "project_id": data.private_project1.id + }) + results = helper_test_http_method(client, 'post', private_url1, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_userstories": "test1\ntest2", + "project_id": data.private_project2.id + }) + results = helper_test_http_method(client, 'post', private_url2, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_userstories": "test1\ntest2", + "project_id": data.blocked_project.id + }) + results = helper_test_http_method(client, 'post', blocked_url, bulk_data, users) + assert results == [401, 403, 403, 451, 451] diff --git a/tests/integration/resources_permissions/test_epics_resources.py b/tests/integration/resources_permissions/test_epics_resources.py new file mode 100644 index 000000000..db59d1a31 --- /dev/null +++ b/tests/integration/resources_permissions/test_epics_resources.py @@ -0,0 +1,900 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.epics.serializers import EpicSerializer +from taiga.projects.epics.models import Epic +from taiga.projects.epics.utils import attach_extra_info as attach_epic_extra_info +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.occ import OCCResourceMixin + +from tests import factories as f +from tests.utils import helper_test_http_method, reconnect_signals +from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_function(function): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)) + ["comment_epic"], + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + epics_csv_uuid=uuid.uuid4().hex, + blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) + + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_epic = f.EpicFactory(project=m.public_project, + status__project=m.public_project) + m.public_epic = attach_epic_extra_info(Epic.objects.all()).get(id=m.public_epic.id) + + m.private_epic1 = f.EpicFactory(project=m.private_project1, + status__project=m.private_project1) + m.private_epic1 = attach_epic_extra_info(Epic.objects.all()).get(id=m.private_epic1.id) + + m.private_epic2 = f.EpicFactory(project=m.private_project2, + status__project=m.private_project2) + m.private_epic2 = attach_epic_extra_info(Epic.objects.all()).get(id=m.private_epic2.id) + + m.blocked_epic = f.EpicFactory(project=m.blocked_project, + status__project=m.blocked_project) + m.blocked_epic = attach_epic_extra_info(Epic.objects.all()).get(id=m.blocked_epic.id) + + + m.public_us = f.UserStoryFactory(project=m.public_project) + m.private_us1 = f.UserStoryFactory(project=m.private_project1) + m.private_us2 = f.UserStoryFactory(project=m.private_project2) + m.blocked_us = f.UserStoryFactory(project=m.blocked_project) + + m.public_related_us = f.RelatedUserStory(epic=m.public_epic, user_story=m.public_us) + m.private_related_us1 = f.RelatedUserStory(epic=m.private_epic1, user_story=m.private_us1) + m.private_related_us2 = f.RelatedUserStory(epic=m.private_epic2, user_story=m.private_us2) + m.blocked_related_us = f.RelatedUserStory(epic=m.blocked_epic, user_story=m.blocked_us) + + m.public_project.default_epic_status = m.public_epic.status + m.public_project.save() + m.private_project1.default_epic_status = m.private_epic1.status + m.private_project1.save() + m.private_project2.default_epic_status = m.private_epic2.status + m.private_project2.save() + m.blocked_project.default_epic_status = m.blocked_epic.status + m.blocked_project.save() + + return m + + +def test_epic_list(client, data): + url = reverse('epics-list') + + response = client.get(url) + epics_data = json.loads(response.content.decode('utf-8')) + assert len(epics_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + epics_data = json.loads(response.content.decode('utf-8')) + assert len(epics_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + epics_data = json.loads(response.content.decode('utf-8')) + assert len(epics_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + epics_data = json.loads(response.content.decode('utf-8')) + assert len(epics_data) == 4 + assert response.status_code == 200 + + +def test_epic_retrieve(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_create(client, data): + url = reverse('epics-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({ + "subject": "test", + "ref": 1, + "project": data.public_project.pk, + "status": data.public_project.epic_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 2, + "project": data.private_project1.pk, + "status": data.private_project1.epic_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 3, + "project": data.private_project2.pk, + "status": data.private_project2.epic_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 3, + "project": data.blocked_project.pk, + "status": data.blocked_project.epic_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_put_update(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + epic_data = EpicSerializer(data.public_epic).data + epic_data["subject"] = "test" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', public_url, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic1).data + epic_data["subject"] = "test" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url1, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic2).data + epic_data["subject"] = "test" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url2, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.blocked_epic).data + epic_data["subject"] = "test" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_put_comment(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + epic_data = EpicSerializer(data.public_epic).data + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', public_url, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic1).data + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url1, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic2).data + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url2, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.blocked_epic).data + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_put_update_and_comment(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + epic_data = EpicSerializer(data.public_epic).data + epic_data["subject"] = "test" + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', public_url, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic1).data + epic_data["subject"] = "test" + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url1, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.private_epic2).data + epic_data["subject"] = "test" + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', private_url2, epic_data, users) + assert results == [401, 403, 403, 200, 200] + + epic_data = EpicSerializer(data.blocked_epic).data + epic_data["subject"] = "test" + epic_data["comment"] = "test comment" + epic_data = json.dumps(epic_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_put_update_with_project_change(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + user3 = f.UserFactory.create() + user4 = f.UserFactory.create() + project1 = f.ProjectFactory() + project2 = f.ProjectFactory() + + epic_status1 = f.EpicStatusFactory.create(project=project1) + epic_status2 = f.EpicStatusFactory.create(project=project2) + + project1.default_epic_status = epic_status1 + project2.default_epic_status = epic_status2 + + project1.save() + project2.save() + + project1 = attach_project_extra_info(Project.objects.all()).get(id=project1.id) + project2 = attach_project_extra_info(Project.objects.all()).get(id=project2.id) + + f.MembershipFactory(project=project1, + user=user1, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user1, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project1, + user=user2, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user3, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + epic = f.EpicFactory.create(project=project1) + epic = attach_epic_extra_info(Epic.objects.all()).get(id=epic.id) + + url = reverse('epics-detail', kwargs={"pk": epic.pk}) + + # Test user with permissions in both projects + client.login(user1) + + epic_data = EpicSerializer(epic).data + epic_data["project"] = project2.id + epic_data = json.dumps(epic_data) + + response = client.put(url, data=epic_data, content_type="application/json") + + assert response.status_code == 200 + + epic.project = project1 + epic.save() + + # Test user with permissions in only origin project + client.login(user2) + + epic_data = EpicSerializer(epic).data + epic_data["project"] = project2.id + epic_data = json.dumps(epic_data) + + response = client.put(url, data=epic_data, content_type="application/json") + + assert response.status_code == 403 + + epic.project = project1 + epic.save() + + # Test user with permissions in only destionation project + client.login(user3) + + epic_data = EpicSerializer(epic).data + epic_data["project"] = project2.id + epic_data = json.dumps(epic_data) + + response = client.put(url, data=epic_data, content_type="application/json") + + assert response.status_code == 403 + + epic.project = project1 + epic.save() + + # Test user without permissions in the projects + client.login(user4) + + epic_data = EpicSerializer(epic).data + epic_data["project"] = project2.id + epic_data = json.dumps(epic_data) + + response = client.put(url, data=epic_data, content_type="application/json") + + assert response.status_code == 403 + + epic.project = project1 + epic.save() + + +def test_epic_patch_update(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"subject": "test", "version": data.public_epic.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_epic1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_epic2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.blocked_epic.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_patch_comment(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"comment": "test comment", "version": data.public_epic.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 200, 200, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_epic1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_epic2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.blocked_epic.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_patch_update_and_comment(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.public_epic.version + }) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_epic1.version + }) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_epic2.version + }) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.blocked_epic.version + }) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_delete(client, data): + public_url = reverse('epics-detail', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-detail', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-detail', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-detail', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_epic_action_bulk_create(client, data): + url = reverse('epics-bulk-create') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + bulk_data = json.dumps({ + "bulk_epics": "test1\ntest2", + "project_id": data.public_epic.project.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_epics": "test1\ntest2", + "project_id": data.private_epic1.project.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_epics": "test1\ntest2", + "project_id": data.private_epic2.project.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_epics": "test1\ntest2", + "project_id": data.blocked_epic.project.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_epic_action_upvote(client, data): + public_url = reverse('epics-upvote', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-upvote', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-upvote', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-upvote', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_epic_action_downvote(client, data): + public_url = reverse('epics-downvote', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-downvote', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-downvote', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-downvote', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_epic_voters_list(client, data): + public_url = reverse('epic-voters-list', kwargs={"resource_id": data.public_epic.pk}) + private_url1 = reverse('epic-voters-list', kwargs={"resource_id": data.private_epic1.pk}) + private_url2 = reverse('epic-voters-list', kwargs={"resource_id": data.private_epic2.pk}) + blocked_url = reverse('epic-voters-list', kwargs={"resource_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_voters_retrieve(client, data): + add_vote(data.public_epic, data.project_owner) + public_url = reverse('epic-voters-detail', kwargs={"resource_id": data.public_epic.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_epic1, data.project_owner) + private_url1 = reverse('epic-voters-detail', kwargs={"resource_id": data.private_epic1.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_epic2, data.project_owner) + private_url2 = reverse('epic-voters-detail', kwargs={"resource_id": data.private_epic2.pk, + "pk": data.project_owner.pk}) + + add_vote(data.blocked_epic, data.project_owner) + blocked_url = reverse('epic-voters-detail', kwargs={"resource_id": data.blocked_epic.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_action_watch(client, data): + public_url = reverse('epics-watch', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-watch', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-watch', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-watch', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_epic_action_unwatch(client, data): + public_url = reverse('epics-unwatch', kwargs={"pk": data.public_epic.pk}) + private_url1 = reverse('epics-unwatch', kwargs={"pk": data.private_epic1.pk}) + private_url2 = reverse('epics-unwatch', kwargs={"pk": data.private_epic2.pk}) + blocked_url = reverse('epics-unwatch', kwargs={"pk": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_epic_watchers_list(client, data): + public_url = reverse('epic-watchers-list', kwargs={"resource_id": data.public_epic.pk}) + private_url1 = reverse('epic-watchers-list', kwargs={"resource_id": data.private_epic1.pk}) + private_url2 = reverse('epic-watchers-list', kwargs={"resource_id": data.private_epic2.pk}) + blocked_url = reverse('epic-watchers-list', kwargs={"resource_id": data.blocked_epic.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_watchers_retrieve(client, data): + add_watcher(data.public_epic, data.project_owner) + public_url = reverse('epic-watchers-detail', kwargs={"resource_id": data.public_epic.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_epic1, data.project_owner) + private_url1 = reverse('epic-watchers-detail', kwargs={"resource_id": data.private_epic1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_epic2, data.project_owner) + private_url2 = reverse('epic-watchers-detail', kwargs={"resource_id": data.private_epic2.pk, + "pk": data.project_owner.pk}) + + add_watcher(data.blocked_epic, data.project_owner) + blocked_url = reverse('epic-watchers-detail', kwargs={"resource_id": data.blocked_epic.pk, + "pk": data.project_owner.pk}) + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epics_csv(client, data): + url = reverse('epics-csv') + csv_public_uuid = data.public_project.epics_csv_uuid + csv_private1_uuid = data.private_project1.epics_csv_uuid + csv_private2_uuid = data.private_project1.epics_csv_uuid + csv_blocked_uuid = data.blocked_project.epics_csv_uuid + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_blocked_uuid), None, users) + assert results == [200, 200, 200, 200, 200] diff --git a/tests/integration/resources_permissions/test_feedback.py b/tests/integration/resources_permissions/test_feedback.py new file mode 100644 index 000000000..654f47bff --- /dev/null +++ b/tests/integration/resources_permissions/test_feedback.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from tests import factories as f +from tests.utils import helper_test_http_method + +from taiga.base.utils import json + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.user = f.UserFactory.create() + return m + + +def test_feedback_create(client, data): + url = reverse("feedback-list") + users = [None, data.user] + + feedback_data = {"comment": "One feedback comment"} + feedback_data = json.dumps(feedback_data) + + results = helper_test_http_method(client, 'post', url, feedback_data, users) + assert results == [401, 200] diff --git a/tests/integration/resources_permissions/test_history_resources.py b/tests/integration/resources_permissions/test_history_resources.py new file mode 100644 index 000000000..c2aeef755 --- /dev/null +++ b/tests/integration/resources_permissions/test_history_resources.py @@ -0,0 +1,1283 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse +from django.utils import timezone + +from taiga.base.utils import json +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.choices import HistoryType +from taiga.projects.history.services import make_key_from_model_object + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create(full_name="registered_user") + m.project_member_with_perms = f.UserFactory.create(full_name="project_member_with_perms") + m.project_member_without_perms = f.UserFactory.create(full_name="project_member_without_perms") + m.project_owner = f.UserFactory.create(full_name="project_owner") + m.other_user = f.UserFactory.create(full_name="other_user") + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + return m + + +######################################################### +## Epics +######################################################### + +@pytest.fixture +def data_epic(data): + m = type("Models", (object,), {}) + m.public_epic = f.EpicFactory(project=data.public_project, ref=22) + m.public_history_entry = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.public_project, + comment="testing public", + key=make_key_from_model_object(m.public_epic), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + + m.private_epic1 = f.EpicFactory(project=data.private_project1, ref=26) + m.private_history_entry1 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project1, + comment="testing 1", + key=make_key_from_model_object(m.private_epic1), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + m.private_epic2 = f.EpicFactory(project=data.private_project2, ref=210) + m.private_history_entry2 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project2, + comment="testing 2", + key=make_key_from_model_object(m.private_epic2), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + return m + + +def test_epic_history_retrieve(client, data, data_epic): + public_url = reverse('epic-history-detail', kwargs={"pk": data_epic.public_epic.pk}) + private_url1 = reverse('epic-history-detail', kwargs={"pk": data_epic.private_epic1.pk}) + private_url2 = reverse('epic-history-detail', kwargs={"pk": data_epic.private_epic2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_action_edit_comment(client, data, data_epic): + public_url = "{}?id={}".format( + reverse('epic-history-edit-comment', kwargs={"pk": data_epic.public_epic.pk}), + data_epic.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('epic-history-edit-comment', kwargs={"pk": data_epic.private_epic1.pk}), + data_epic.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('epic-history-edit-comment', kwargs={"pk": data_epic.private_epic2.pk}), + data_epic.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + data = json.dumps({"comment": "testing update comment"}) + + results = helper_test_http_method(client, 'post', public_url, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, data, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_action_delete_comment(client, data, data_epic): + public_url = "{}?id={}".format( + reverse('epic-history-delete-comment', kwargs={"pk": data_epic.public_epic.pk}), + data_epic.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('epic-history-delete-comment', kwargs={"pk": data_epic.private_epic1.pk}), + data_epic.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('epic-history-delete-comment', kwargs={"pk": data_epic.private_epic2.pk}), + data_epic.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_epic.public_history_entry.delete_comment_date = None + data_epic.public_history_entry.delete_comment_user = None + data_epic.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_epic.private_history_entry1.delete_comment_date = None + data_epic.private_history_entry1.delete_comment_user = None + data_epic.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_epic.private_history_entry2.delete_comment_date = None + data_epic.private_history_entry2.delete_comment_user = None + data_epic.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_epic_action_undelete_comment(client, data, data_epic): + public_url = "{}?id={}".format( + reverse('epic-history-undelete-comment', kwargs={"pk": data_epic.public_epic.pk}), + data_epic.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('epic-history-undelete-comment', kwargs={"pk": data_epic.private_epic1.pk}), + data_epic.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('epic-history-undelete-comment', kwargs={"pk": data_epic.private_epic2.pk}), + data_epic.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_epic.public_history_entry.delete_comment_date = timezone.now() + data_epic.public_history_entry.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_epic.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_epic.private_history_entry1.delete_comment_date = timezone.now() + data_epic.private_history_entry1.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_epic.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_epic.private_history_entry2.delete_comment_date = timezone.now() + data_epic.private_history_entry2.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_epic.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_epic_action_comment_versions(client, data, data_epic): + public_url = "{}?id={}".format( + reverse('epic-history-comment-versions', kwargs={"pk": data_epic.public_epic.pk}), + data_epic.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('epic-history-comment-versions', kwargs={"pk": data_epic.private_epic1.pk}), + data_epic.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('epic-history-comment-versions', kwargs={"pk": data_epic.private_epic2.pk}), + data_epic.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner, + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +######################################################### +## User stories +######################################################### + +@pytest.fixture +def data_us(data): + m = type("Models", (object,), {}) + m.public_user_story = f.UserStoryFactory(project=data.public_project, ref=1) + m.public_history_entry = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.public_project, + comment="testing public", + key=make_key_from_model_object(m.public_user_story), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + + m.private_user_story1 = f.UserStoryFactory(project=data.private_project1, ref=5) + m.private_history_entry1 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project1, + comment="testing 1", + key=make_key_from_model_object(m.private_user_story1), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + m.private_user_story2 = f.UserStoryFactory(project=data.private_project2, ref=9) + m.private_history_entry2 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project2, + comment="testing 2", + key=make_key_from_model_object(m.private_user_story2), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + return m + + +def test_user_story_history_retrieve(client, data, data_us): + public_url = reverse('userstory-history-detail', kwargs={"pk": data_us.public_user_story.pk}) + private_url1 = reverse('userstory-history-detail', kwargs={"pk": data_us.private_user_story1.pk}) + private_url2 = reverse('userstory-history-detail', kwargs={"pk": data_us.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_user_story_action_edit_comment(client, data, data_us): + public_url = "{}?id={}".format( + reverse('userstory-history-edit-comment', kwargs={"pk": data_us.public_user_story.pk}), + data_us.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('userstory-history-edit-comment', kwargs={"pk": data_us.private_user_story1.pk}), + data_us.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('userstory-history-edit-comment', kwargs={"pk": data_us.private_user_story2.pk}), + data_us.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + data = json.dumps({"comment": "testing update comment"}) + + results = helper_test_http_method(client, 'post', public_url, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, data, users) + assert results == [401, 403, 403, 200, 200] + + +def test_user_story_action_delete_comment(client, data, data_us): + public_url = "{}?id={}".format( + reverse('userstory-history-delete-comment', kwargs={"pk": data_us.public_user_story.pk}), + data_us.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('userstory-history-delete-comment', kwargs={"pk": data_us.private_user_story1.pk}), + data_us.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('userstory-history-delete-comment', kwargs={"pk": data_us.private_user_story2.pk}), + data_us.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_us.public_history_entry.delete_comment_date = None + data_us.public_history_entry.delete_comment_user = None + data_us.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_us.private_history_entry1.delete_comment_date = None + data_us.private_history_entry1.delete_comment_user = None + data_us.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_us.private_history_entry2.delete_comment_date = None + data_us.private_history_entry2.delete_comment_user = None + data_us.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_user_story_action_undelete_comment(client, data, data_us): + public_url = "{}?id={}".format( + reverse('userstory-history-undelete-comment', kwargs={"pk": data_us.public_user_story.pk}), + data_us.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('userstory-history-undelete-comment', kwargs={"pk": data_us.private_user_story1.pk}), + data_us.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('userstory-history-undelete-comment', kwargs={"pk": data_us.private_user_story2.pk}), + data_us.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_us.public_history_entry.delete_comment_date = timezone.now() + data_us.public_history_entry.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_us.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_us.private_history_entry1.delete_comment_date = timezone.now() + data_us.private_history_entry1.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_us.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_us.private_history_entry2.delete_comment_date = timezone.now() + data_us.private_history_entry2.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_us.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_user_story_action_comment_versions(client, data, data_us): + public_url = "{}?id={}".format( + reverse('userstory-history-comment-versions', kwargs={"pk": data_us.public_user_story.pk}), + data_us.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('userstory-history-comment-versions', kwargs={"pk": data_us.private_user_story1.pk}), + data_us.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('userstory-history-comment-versions', kwargs={"pk": data_us.private_user_story2.pk}), + data_us.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner, + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +######################################################### +## Tasks +######################################################### + +@pytest.fixture +def data_task(data): + m = type("Models", (object,), {}) + m.public_task = f.TaskFactory(project=data.public_project, ref=2) + m.public_history_entry = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.public_project, + comment="testing public", + key=make_key_from_model_object(m.public_task), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + + m.private_task1 = f.TaskFactory(project=data.private_project1, ref=6) + m.private_history_entry1 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project1, + comment="testing 1", + key=make_key_from_model_object(m.private_task1), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + m.private_task2 = f.TaskFactory(project=data.private_project2, ref=10) + m.private_history_entry2 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project2, + comment="testing 2", + key=make_key_from_model_object(m.private_task2), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + return m + + +def test_task_history_retrieve(client, data, data_task): + public_url = reverse('task-history-detail', kwargs={"pk": data_task.public_task.pk}) + private_url1 = reverse('task-history-detail', kwargs={"pk": data_task.private_task1.pk}) + private_url2 = reverse('task-history-detail', kwargs={"pk": data_task.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_action_edit_comment(client, data, data_task): + public_url = "{}?id={}".format( + reverse('task-history-edit-comment', kwargs={"pk": data_task.public_task.pk}), + data_task.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('task-history-edit-comment', kwargs={"pk": data_task.private_task1.pk}), + data_task.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('task-history-edit-comment', kwargs={"pk": data_task.private_task2.pk}), + data_task.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + data = json.dumps({"comment": "testing update comment"}) + + results = helper_test_http_method(client, 'post', public_url, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, data, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_action_delete_comment(client, data, data_task): + public_url = "{}?id={}".format( + reverse('task-history-delete-comment', kwargs={"pk": data_task.public_task.pk}), + data_task.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('task-history-delete-comment', kwargs={"pk": data_task.private_task1.pk}), + data_task.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('task-history-delete-comment', kwargs={"pk": data_task.private_task2.pk}), + data_task.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_task.public_history_entry.delete_comment_date = None + data_task.public_history_entry.delete_comment_user = None + data_task.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_task.private_history_entry1.delete_comment_date = None + data_task.private_history_entry1.delete_comment_user = None + data_task.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_task.private_history_entry2.delete_comment_date = None + data_task.private_history_entry2.delete_comment_user = None + data_task.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_task_action_undelete_comment(client, data, data_task): + public_url = "{}?id={}".format( + reverse('task-history-undelete-comment', kwargs={"pk": data_task.public_task.pk}), + data_task.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('task-history-undelete-comment', kwargs={"pk": data_task.private_task1.pk}), + data_task.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('task-history-undelete-comment', kwargs={"pk": data_task.private_task2.pk}), + data_task.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_task.public_history_entry.delete_comment_date = timezone.now() + data_task.public_history_entry.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_task.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_task.private_history_entry1.delete_comment_date = timezone.now() + data_task.private_history_entry1.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_task.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_task.private_history_entry2.delete_comment_date = timezone.now() + data_task.private_history_entry2.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_task.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_task_action_comment_versions(client, data, data_task): + public_url = "{}?id={}".format( + reverse('task-history-comment-versions', kwargs={"pk": data_task.public_task.pk}), + data_task.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('task-history-comment-versions', kwargs={"pk": data_task.private_task1.pk}), + data_task.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('task-history-comment-versions', kwargs={"pk": data_task.private_task2.pk}), + data_task.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner, + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +######################################################### +## Issues +######################################################### + +@pytest.fixture +def data_issue(data): + m = type("Models", (object,), {}) + m.public_issue = f.IssueFactory(project=data.public_project, ref=3) + m.public_history_entry = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.public_project, + comment="testing public", + key=make_key_from_model_object(m.public_issue), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + + m.private_issue1 = f.IssueFactory(project=data.private_project1, ref=7) + m.private_history_entry1 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project1, + comment="testing 1", + key=make_key_from_model_object(m.private_issue1), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + m.private_issue2 = f.IssueFactory(project=data.private_project2, ref=11) + m.private_history_entry2 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project2, + comment="testing 2", + key=make_key_from_model_object(m.private_issue2), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + return m + + +def test_issue_history_retrieve(client, data, data_issue): + public_url = reverse('issue-history-detail', kwargs={"pk": data_issue.public_issue.pk}) + private_url1 = reverse('issue-history-detail', kwargs={"pk": data_issue.private_issue1.pk}) + private_url2 = reverse('issue-history-detail', kwargs={"pk": data_issue.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_action_edit_comment(client, data, data_issue): + public_url = "{}?id={}".format( + reverse('issue-history-edit-comment', kwargs={"pk": data_issue.public_issue.pk}), + data_issue.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('issue-history-edit-comment', kwargs={"pk": data_issue.private_issue1.pk}), + data_issue.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('issue-history-edit-comment', kwargs={"pk": data_issue.private_issue2.pk}), + data_issue.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + data = json.dumps({"comment": "testing update comment"}) + + results = helper_test_http_method(client, 'post', public_url, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, data, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_action_delete_comment(client, data, data_issue): + public_url = "{}?id={}".format( + reverse('issue-history-delete-comment', kwargs={"pk": data_issue.public_issue.pk}), + data_issue.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('issue-history-delete-comment', kwargs={"pk": data_issue.private_issue1.pk}), + data_issue.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('issue-history-delete-comment', kwargs={"pk": data_issue.private_issue2.pk}), + data_issue.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_issue.public_history_entry.delete_comment_date = None + data_issue.public_history_entry.delete_comment_user = None + data_issue.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_issue.private_history_entry1.delete_comment_date = None + data_issue.private_history_entry1.delete_comment_user = None + data_issue.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_issue.private_history_entry2.delete_comment_date = None + data_issue.private_history_entry2.delete_comment_user = None + data_issue.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_issue_action_undelete_comment(client, data, data_issue): + public_url = "{}?id={}".format( + reverse('issue-history-undelete-comment', kwargs={"pk": data_issue.public_issue.pk}), + data_issue.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('issue-history-undelete-comment', kwargs={"pk": data_issue.private_issue1.pk}), + data_issue.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('issue-history-undelete-comment', kwargs={"pk": data_issue.private_issue2.pk}), + data_issue.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_issue.public_history_entry.delete_comment_date = timezone.now() + data_issue.public_history_entry.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_issue.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_issue.private_history_entry1.delete_comment_date = timezone.now() + data_issue.private_history_entry1.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_issue.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_issue.private_history_entry2.delete_comment_date = timezone.now() + data_issue.private_history_entry2.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_issue.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_issue_action_comment_versions(client, data, data_issue): + public_url = "{}?id={}".format( + reverse('issue-history-comment-versions', kwargs={"pk": data_issue.public_issue.pk}), + data_issue.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('issue-history-comment-versions', kwargs={"pk": data_issue.private_issue1.pk}), + data_issue.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('issue-history-comment-versions', kwargs={"pk": data_issue.private_issue2.pk}), + data_issue.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner, + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +######################################################### +## Wiki pages +######################################################### + +@pytest.fixture +def data_wiki(data): + m = type("Models", (object,), {}) + m.public_wiki = f.WikiPageFactory(project=data.public_project, slug=4) + m.public_history_entry = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.public_project, + comment="testing public", + key=make_key_from_model_object(m.public_wiki), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + + m.private_wiki1 = f.WikiPageFactory(project=data.private_project1, slug=8) + m.private_history_entry1 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project1, + comment="testing 1", + key=make_key_from_model_object(m.private_wiki1), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + m.private_wiki2 = f.WikiPageFactory(project=data.private_project2, slug=12) + m.private_history_entry2 = f.HistoryEntryFactory.create(type=HistoryType.change, + project=data.private_project2, + comment="testing 2", + key=make_key_from_model_object(m.private_wiki2), + diff={}, + user={"pk": data.project_member_with_perms.pk}) + return m + + +def test_wiki_history_retrieve(client, data, data_wiki): + public_url = reverse('wiki-history-detail', kwargs={"pk": data_wiki.public_wiki.pk}) + private_url1 = reverse('wiki-history-detail', kwargs={"pk": data_wiki.private_wiki1.pk}) + private_url2 = reverse('wiki-history-detail', kwargs={"pk": data_wiki.private_wiki2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_wiki_action_edit_comment(client, data, data_wiki): + public_url = "{}?id={}".format( + reverse('wiki-history-edit-comment', kwargs={"pk": data_wiki.public_wiki.pk}), + data_wiki.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('wiki-history-edit-comment', kwargs={"pk": data_wiki.private_wiki1.pk}), + data_wiki.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('wiki-history-edit-comment', kwargs={"pk": data_wiki.private_wiki2.pk}), + data_wiki.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + data = json.dumps({"comment": "testing update comment"}) + + results = helper_test_http_method(client, 'post', public_url, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, data, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, data, users) + assert results == [401, 403, 403, 200, 200] + + +def test_wiki_action_delete_comment(client, data, data_wiki): + public_url = "{}?id={}".format( + reverse('wiki-history-delete-comment', kwargs={"pk": data_wiki.public_wiki.pk}), + data_wiki.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('wiki-history-delete-comment', kwargs={"pk": data_wiki.private_wiki1.pk}), + data_wiki.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('wiki-history-delete-comment', kwargs={"pk": data_wiki.private_wiki2.pk}), + data_wiki.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_wiki.public_history_entry.delete_comment_date = None + data_wiki.public_history_entry.delete_comment_user = None + data_wiki.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_wiki.private_history_entry1.delete_comment_date = None + data_wiki.private_history_entry1.delete_comment_user = None + data_wiki.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_wiki.private_history_entry2.delete_comment_date = None + data_wiki.private_history_entry2.delete_comment_user = None + data_wiki.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_wiki_action_undelete_comment(client, data, data_wiki): + public_url = "{}?id={}".format( + reverse('wiki-history-undelete-comment', kwargs={"pk": data_wiki.public_wiki.pk}), + data_wiki.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('wiki-history-undelete-comment', kwargs={"pk": data_wiki.private_wiki1.pk}), + data_wiki.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('wiki-history-undelete-comment', kwargs={"pk": data_wiki.private_wiki2.pk}), + data_wiki.private_history_entry2.id + ) + + users_and_statuses = [ + (None, 401), + (data.registered_user, 403), + (data.project_member_without_perms, 403), + (data.project_member_with_perms, 200), + (data.project_owner, 200), + ] + + for user, status_code in users_and_statuses: + data_wiki.public_history_entry.delete_comment_date = timezone.now() + data_wiki.public_history_entry.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_wiki.public_history_entry.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(public_url) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_wiki.private_history_entry1.delete_comment_date = timezone.now() + data_wiki.private_history_entry1.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_wiki.private_history_entry1.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url1) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + for user, status_code in users_and_statuses: + data_wiki.private_history_entry2.delete_comment_date = timezone.now() + data_wiki.private_history_entry2.delete_comment_user = {"pk": data.project_member_with_perms.pk} + data_wiki.private_history_entry2.save() + + if user: + client.login(user) + else: + client.logout() + response = client.json.post(private_url2) + error_mesage = "{} != {} for {}".format(response.status_code, status_code, user) + assert response.status_code == status_code, error_mesage + + +def test_wiki_action_comment_versions(client, data, data_wiki): + public_url = "{}?id={}".format( + reverse('wiki-history-comment-versions', kwargs={"pk": data_wiki.public_wiki.pk}), + data_wiki.public_history_entry.id + ) + private_url1 = "{}?id={}".format( + reverse('wiki-history-comment-versions', kwargs={"pk": data_wiki.private_wiki1.pk}), + data_wiki.private_history_entry1.id + ) + private_url2 = "{}?id={}".format( + reverse('wiki-history-comment-versions', kwargs={"pk": data_wiki.private_wiki2.pk}), + data_wiki.private_history_entry2.id + ) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner, + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_issues_custom_attributes_resource.py b/tests/integration/resources_permissions/test_issues_custom_attributes_resource.py new file mode 100644 index 000000000..5c1ee1edf --- /dev/null +++ b/tests/integration/resources_permissions/test_issues_custom_attributes_resource.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.custom_attributes import serializers +from taiga.permissions.choices import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS) + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project2, + role__permissions=[]) + + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_issue_ca = f.IssueCustomAttributeFactory(project=m.public_project) + m.private_issue_ca1 = f.IssueCustomAttributeFactory(project=m.private_project1) + m.private_issue_ca2 = f.IssueCustomAttributeFactory(project=m.private_project2) + m.blocked_issue_ca = f.IssueCustomAttributeFactory(project=m.blocked_project) + + m.public_issue = f.IssueFactory(project=m.public_project, + status__project=m.public_project, + severity__project=m.public_project, + priority__project=m.public_project, + type__project=m.public_project, + milestone__project=m.public_project) + m.private_issue1 = f.IssueFactory(project=m.private_project1, + status__project=m.private_project1, + severity__project=m.private_project1, + priority__project=m.private_project1, + type__project=m.private_project1, + milestone__project=m.private_project1) + m.private_issue2 = f.IssueFactory(project=m.private_project2, + status__project=m.private_project2, + severity__project=m.private_project2, + priority__project=m.private_project2, + type__project=m.private_project2, + milestone__project=m.private_project2) + m.blocked_issue = f.IssueFactory(project=m.blocked_project, + status__project=m.blocked_project, + severity__project=m.blocked_project, + priority__project=m.blocked_project, + type__project=m.blocked_project, + milestone__project=m.blocked_project) + + m.public_issue_cav = m.public_issue.custom_attributes_values + m.private_issue_cav1 = m.private_issue1.custom_attributes_values + m.private_issue_cav2 = m.private_issue2.custom_attributes_values + m.blocked_issue_cav = m.blocked_issue.custom_attributes_values + + return m + + +######################################################### +# Issue Custom Attribute +######################################################### + +def test_issue_custom_attribute_retrieve(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.pk}) + blocked_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.blocked_issue_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_custom_attribute_create(client, data): + public_url = reverse('issue-custom-attributes-list') + private1_url = reverse('issue-custom-attributes-list') + private2_url = reverse('issue-custom-attributes-list') + blocked_url = reverse('issue-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_ca_data = {"name": "test-new", "project": data.public_project.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', public_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + issue_ca_data = {"name": "test-new", "project": data.private_project1.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', private1_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + issue_ca_data = {"name": "test-new", "project": data.private_project2.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', private2_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + issue_ca_data = {"name": "test-new", "project": data.blocked_project.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', private2_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_custom_attribute_update(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.pk}) + blocked_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.blocked_issue_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.public_issue_ca).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', public_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.private_issue_ca1).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', private1_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.private_issue_ca2).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', private2_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.blocked_issue_ca).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', blocked_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_custom_attribute_delete(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.pk}) + blocked_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.blocked_issue_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_custom_attribute_list(client, data): + url = reverse('issue-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + +def test_issue_custom_attribute_patch(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.pk}) + blocked_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.blocked_issue_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_custom_attribute_action_bulk_update_order(client, data): + url = reverse('issue-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + +######################################################### +# Issue Custom Attribute +######################################################### + + +def test_issue_custom_attributes_values_retrieve(client, data): + public_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.public_issue.pk}) + private_url1 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue1.pk}) + private_url2 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue2.pk}) + blocked_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_custom_attributes_values_update(client, data): + public_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.public_issue.pk}) + private_url1 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue1.pk}) + private_url2 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue2.pk}) + blocked_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_data = serializers.IssueCustomAttributesValuesSerializer(data.public_issue_cav).data + issue_data["attributes_values"] = {str(data.public_issue_ca.pk): "test"} + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', public_url, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = serializers.IssueCustomAttributesValuesSerializer(data.private_issue_cav1).data + issue_data["attributes_values"] = {str(data.private_issue_ca1.pk): "test"} + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url1, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = serializers.IssueCustomAttributesValuesSerializer(data.private_issue_cav2).data + issue_data["attributes_values"] = {str(data.private_issue_ca2.pk): "test"} + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url2, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = serializers.IssueCustomAttributesValuesSerializer(data.blocked_issue_cav).data + issue_data["attributes_values"] = {str(data.blocked_issue_ca.pk): "test"} + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', blocked_url, issue_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_custom_attributes_values_patch(client, data): + public_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.public_issue.pk}) + private_url1 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue1.pk}) + private_url2 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue2.pk}) + blocked_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_issue_ca.pk): "test"}, + "version": data.public_issue.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_issue_ca1.pk): "test"}, + "version": data.private_issue1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_issue_ca2.pk): "test"}, + "version": data.private_issue2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.blocked_issue_ca.pk): "test"}, + "version": data.blocked_issue.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] diff --git a/tests/integration/resources_permissions/test_issues_resources.py b/tests/integration/resources_permissions/test_issues_resources.py new file mode 100644 index 000000000..f88b7a670 --- /dev/null +++ b/tests/integration/resources_permissions/test_issues_resources.py @@ -0,0 +1,957 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.urls import reverse + +from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.projects.issues.models import Issue +from taiga.projects.issues.serializers import IssueSerializer +from taiga.projects.issues.utils import attach_extra_info as attach_issue_extra_info +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.base.utils import json + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals +from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher +from taiga.projects.occ import OCCResourceMixin + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)) + ["comment_issue"], + owner=m.project_owner, + issues_csv_uuid=uuid.uuid4().hex) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + issues_csv_uuid=uuid.uuid4().hex) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + issues_csv_uuid=uuid.uuid4().hex) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + issues_csv_uuid=uuid.uuid4().hex, + blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_issue = f.IssueFactory(project=m.public_project, + status__project=m.public_project, + severity__project=m.public_project, + priority__project=m.public_project, + type__project=m.public_project, + milestone__project=m.public_project) + m.public_issue = attach_issue_extra_info(Issue.objects.all()).get(id=m.public_issue.id) + + m.private_issue1 = f.IssueFactory(project=m.private_project1, + status__project=m.private_project1, + severity__project=m.private_project1, + priority__project=m.private_project1, + type__project=m.private_project1, + milestone__project=m.private_project1) + m.private_issue1 = attach_issue_extra_info(Issue.objects.all()).get(id=m.private_issue1.id) + + m.private_issue2 = f.IssueFactory(project=m.private_project2, + status__project=m.private_project2, + severity__project=m.private_project2, + priority__project=m.private_project2, + type__project=m.private_project2, + milestone__project=m.private_project2) + m.private_issue2 = attach_issue_extra_info(Issue.objects.all()).get(id=m.private_issue2.id) + + m.blocked_issue = f.IssueFactory(project=m.blocked_project, + status__project=m.blocked_project, + severity__project=m.blocked_project, + priority__project=m.blocked_project, + type__project=m.blocked_project, + milestone__project=m.blocked_project) + m.blocked_issue = attach_issue_extra_info(Issue.objects.all()).get(id=m.blocked_issue.id) + + return m + + +def test_issue_list(client, data): + url = reverse('issues-list') + + response = client.get(url) + issues_data = json.loads(response.content.decode('utf-8')) + assert len(issues_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + issues_data = json.loads(response.content.decode('utf-8')) + assert len(issues_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + issues_data = json.loads(response.content.decode('utf-8')) + assert len(issues_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + issues_data = json.loads(response.content.decode('utf-8')) + assert len(issues_data) == 4 + assert response.status_code == 200 + + +def test_issue_list_filter_by_project_ok(client, data): + url = "{}?project={}".format(reverse("issues-list"), data.public_project.pk) + + client.login(data.project_owner) + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 1 + + +def test_issue_list_filter_by_project_error(client, data): + url = "{}?project={}".format(reverse("issues-list"), "-ERROR-") + + client.login(data.project_owner) + response = client.get(url) + + assert response.status_code == 400 + + +def test_issue_retrieve(client, data): + public_url = reverse('issues-detail', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-detail', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-detail', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-detail', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_create(client, data): + url = reverse('issues-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({ + "subject": "test", + "ref": 1, + "project": data.public_project.pk, + "severity": data.public_project.severities.all()[0].pk, + "priority": data.public_project.priorities.all()[0].pk, + "status": data.public_project.issue_statuses.all()[0].pk, + "type": data.public_project.issue_types.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 2, + "project": data.private_project1.pk, + "severity": data.private_project1.severities.all()[0].pk, + "priority": data.private_project1.priorities.all()[0].pk, + "status": data.private_project1.issue_statuses.all()[0].pk, + "type": data.private_project1.issue_types.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 3, + "project": data.private_project2.pk, + "severity": data.private_project2.severities.all()[0].pk, + "priority": data.private_project2.priorities.all()[0].pk, + "status": data.private_project2.issue_statuses.all()[0].pk, + "type": data.private_project2.issue_types.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 3, + "project": data.blocked_project.pk, + "severity": data.blocked_project.severities.all()[0].pk, + "priority": data.blocked_project.priorities.all()[0].pk, + "status": data.blocked_project.issue_statuses.all()[0].pk, + "type": data.blocked_project.issue_types.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_put_update(client, data): + public_url = reverse('issues-detail', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-detail', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-detail', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-detail', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + issue_data = IssueSerializer(data.public_issue).data + issue_data["subject"] = "test" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', public_url, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = IssueSerializer(data.private_issue1).data + issue_data["subject"] = "test" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url1, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = IssueSerializer(data.private_issue2).data + issue_data["subject"] = "test" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url2, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = IssueSerializer(data.blocked_issue).data + issue_data["subject"] = "test" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', blocked_url, issue_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_put_comment(client, data): + public_url = reverse('issues-detail', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-detail', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-detail', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-detail', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + issue_data = IssueSerializer(data.public_issue).data + issue_data["comment"] = "test comment" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', public_url, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = IssueSerializer(data.private_issue1).data + issue_data["comment"] = "test comment" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url1, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = IssueSerializer(data.private_issue2).data + issue_data["comment"] = "test comment" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url2, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = IssueSerializer(data.blocked_issue).data + issue_data["comment"] = "test comment" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', blocked_url, issue_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_put_update_and_comment(client, data): + public_url = reverse('issues-detail', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-detail', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-detail', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-detail', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + issue_data = IssueSerializer(data.public_issue).data + issue_data["subject"] = "test" + issue_data["comment"] = "test comment" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', public_url, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = IssueSerializer(data.private_issue1).data + issue_data["subject"] = "test" + issue_data["comment"] = "test comment" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url1, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = IssueSerializer(data.private_issue2).data + issue_data["subject"] = "test" + issue_data["comment"] = "test comment" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url2, issue_data, users) + assert results == [401, 403, 403, 200, 200] + + issue_data = IssueSerializer(data.blocked_issue).data + issue_data["subject"] = "test" + issue_data["comment"] = "test comment" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', blocked_url, issue_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_put_update_with_project_change(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + user3 = f.UserFactory.create() + user4 = f.UserFactory.create() + project1 = f.ProjectFactory() + project2 = f.ProjectFactory() + + issue_status1 = f.IssueStatusFactory.create(project=project1) + issue_status2 = f.IssueStatusFactory.create(project=project2) + + priority1 = f.PriorityFactory.create(project=project1) + priority2 = f.PriorityFactory.create(project=project2) + + severity1 = f.SeverityFactory.create(project=project1) + severity2 = f.SeverityFactory.create(project=project2) + + issue_type1 = f.IssueTypeFactory.create(project=project1) + issue_type2 = f.IssueTypeFactory.create(project=project2) + + project1.default_issue_status = issue_status1 + project2.default_issue_status = issue_status2 + + project1.default_priority = priority1 + project2.default_priority = priority2 + + project1.default_severity = severity1 + project2.default_severity = severity2 + + project1.default_issue_type = issue_type1 + project2.default_issue_type = issue_type2 + + project1.save() + project2.save() + + project1 = attach_project_extra_info(Project.objects.all()).get(id=project1.id) + project2 = attach_project_extra_info(Project.objects.all()).get(id=project2.id) + + f.MembershipFactory(project=project1, + user=user1, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user1, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project1, + user=user2, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user3, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + issue = f.IssueFactory.create(project=project1) + issue = attach_issue_extra_info(Issue.objects.all()).get(id=issue.id) + + url = reverse('issues-detail', kwargs={"pk": issue.pk}) + + # Test user with permissions in both projects + client.login(user1) + + issue_data = IssueSerializer(issue).data + issue_data["project"] = project2.id + issue_data = json.dumps(issue_data) + + response = client.put(url, data=issue_data, content_type="application/json") + + assert response.status_code == 200 + + issue.project = project1 + issue.save() + + # Test user with permissions in only origin project + client.login(user2) + + issue_data = IssueSerializer(issue).data + issue_data["project"] = project2.id + issue_data = json.dumps(issue_data) + + response = client.put(url, data=issue_data, content_type="application/json") + + assert response.status_code == 403 + + issue.project = project1 + issue.save() + + # Test user with permissions in only destionation project + client.login(user3) + + issue_data = IssueSerializer(issue).data + issue_data["project"] = project2.id + issue_data = json.dumps(issue_data) + + response = client.put(url, data=issue_data, content_type="application/json") + + assert response.status_code == 403 + + issue.project = project1 + issue.save() + + # Test user without permissions in the projects + client.login(user4) + + issue_data = IssueSerializer(issue).data + issue_data["project"] = project2.id + issue_data = json.dumps(issue_data) + + response = client.put(url, data=issue_data, content_type="application/json") + + assert response.status_code == 403 + + issue.project = project1 + issue.save() + + +def test_issue_patch_update(client, data): + public_url = reverse('issues-detail', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-detail', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-detail', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-detail', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"subject": "test", "version": data.public_issue.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_issue1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_issue2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.blocked_issue.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_patch_comment(client, data): + public_url = reverse('issues-detail', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-detail', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-detail', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-detail', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"comment": "test comment", "version": data.public_issue.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 200, 200, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_issue1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_issue2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.blocked_issue.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_patch_update_and_comment(client, data): + public_url = reverse('issues-detail', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-detail', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-detail', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-detail', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.public_issue.version + }) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_issue1.version + }) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_issue2.version + }) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.blocked_issue.version + }) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_delete(client, data): + public_url = reverse('issues-detail', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-detail', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-detail', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-detail', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_issue_action_bulk_create(client, data): + data.public_issue.project.default_issue_status = f.IssueStatusFactory() + data.public_issue.project.default_issue_type = f.IssueTypeFactory() + data.public_issue.project.default_priority = f.PriorityFactory() + data.public_issue.project.default_severity = f.SeverityFactory() + data.public_issue.project.save() + + data.private_issue1.project.default_issue_status = f.IssueStatusFactory() + data.private_issue1.project.default_issue_type = f.IssueTypeFactory() + data.private_issue1.project.default_priority = f.PriorityFactory() + data.private_issue1.project.default_severity = f.SeverityFactory() + data.private_issue1.project.save() + + data.private_issue2.project.default_issue_status = f.IssueStatusFactory() + data.private_issue2.project.default_issue_type = f.IssueTypeFactory() + data.private_issue2.project.default_priority = f.PriorityFactory() + data.private_issue2.project.default_severity = f.SeverityFactory() + data.private_issue2.project.save() + + data.blocked_issue.project.default_issue_status = f.IssueStatusFactory() + data.blocked_issue.project.default_issue_type = f.IssueTypeFactory() + data.blocked_issue.project.default_priority = f.PriorityFactory() + data.blocked_issue.project.default_severity = f.SeverityFactory() + data.blocked_issue.project.save() + + url = reverse('issues-bulk-create') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + bulk_data = json.dumps({"bulk_issues": "test1\ntest2", + "project_id": data.public_issue.project.pk}) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({"bulk_issues": "test1\ntest2", + "project_id": data.private_issue1.project.pk}) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({"bulk_issues": "test1\ntest2", + "project_id": data.private_issue2.project.pk}) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({"bulk_issues": "test1\ntest2", + "project_id": data.blocked_issue.project.pk}) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_issue_action_upvote(client, data): + public_url = reverse('issues-upvote', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-upvote', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-upvote', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-upvote', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_issue_action_downvote(client, data): + public_url = reverse('issues-downvote', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-downvote', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-downvote', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-downvote', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_issue_voters_list(client, data): + public_url = reverse('issue-voters-list', kwargs={"resource_id": data.public_issue.pk}) + private_url1 = reverse('issue-voters-list', kwargs={"resource_id": data.private_issue1.pk}) + private_url2 = reverse('issue-voters-list', kwargs={"resource_id": data.private_issue2.pk}) + blocked_url = reverse('issue-voters-list', kwargs={"resource_id": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_voters_retrieve(client, data): + add_vote(data.public_issue, data.project_owner) + public_url = reverse('issue-voters-detail', kwargs={"resource_id": data.public_issue.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_issue1, data.project_owner) + private_url1 = reverse('issue-voters-detail', kwargs={"resource_id": data.private_issue1.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_issue2, data.project_owner) + private_url2 = reverse('issue-voters-detail', kwargs={"resource_id": data.private_issue2.pk, + "pk": data.project_owner.pk}) + add_vote(data.blocked_issue, data.project_owner) + blocked_url = reverse('issue-voters-detail', kwargs={"resource_id": data.blocked_issue.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_action_watch(client, data): + public_url = reverse('issues-watch', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-watch', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-watch', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-watch', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_issue_action_unwatch(client, data): + public_url = reverse('issues-unwatch', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-unwatch', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-unwatch', kwargs={"pk": data.private_issue2.pk}) + blocked_url = reverse('issues-unwatch', kwargs={"pk": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_issue_watchers_list(client, data): + public_url = reverse('issue-watchers-list', kwargs={"resource_id": data.public_issue.pk}) + private_url1 = reverse('issue-watchers-list', kwargs={"resource_id": data.private_issue1.pk}) + private_url2 = reverse('issue-watchers-list', kwargs={"resource_id": data.private_issue2.pk}) + blocked_url = reverse('issue-watchers-list', kwargs={"resource_id": data.blocked_issue.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_watchers_retrieve(client, data): + add_watcher(data.public_issue, data.project_owner) + public_url = reverse('issue-watchers-detail', kwargs={"resource_id": data.public_issue.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_issue1, data.project_owner) + private_url1 = reverse('issue-watchers-detail', kwargs={"resource_id": data.private_issue1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_issue2, data.project_owner) + private_url2 = reverse('issue-watchers-detail', kwargs={"resource_id": data.private_issue2.pk, + "pk": data.project_owner.pk}) + add_watcher(data.blocked_issue, data.project_owner) + blocked_url = reverse('issue-watchers-detail', kwargs={"resource_id": data.blocked_issue.pk, + "pk": data.project_owner.pk}) + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issues_csv(client, data): + url = reverse('issues-csv') + csv_public_uuid = data.public_project.issues_csv_uuid + csv_private1_uuid = data.private_project1.issues_csv_uuid + csv_private2_uuid = data.private_project2.issues_csv_uuid + csv_blocked_uuid = data.blocked_project.issues_csv_uuid + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_blocked_uuid), None, users) + assert results == [200, 200, 200, 200, 200] diff --git a/tests/integration/resources_permissions/test_milestones_resources.py b/tests/integration/resources_permissions/test_milestones_resources.py new file mode 100644 index 000000000..b7a063a9f --- /dev/null +++ b/tests/integration/resources_permissions/test_milestones_resources.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json + +from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.projects.milestones.serializers import MilestoneSerializer +from taiga.projects.milestones.models import Milestone +from taiga.projects.milestones.utils import attach_extra_info as attach_milestone_extra_info +from taiga.projects.notifications.services import add_watcher +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) + + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_milestone = f.MilestoneFactory(project=m.public_project) + m.public_milestone = attach_milestone_extra_info(Milestone.objects.all()).get(id=m.public_milestone.id) + m.private_milestone1 = f.MilestoneFactory(project=m.private_project1) + m.private_milestone1 = attach_milestone_extra_info(Milestone.objects.all()).get(id=m.private_milestone1.id) + m.private_milestone2 = f.MilestoneFactory(project=m.private_project2) + m.private_milestone2 = attach_milestone_extra_info(Milestone.objects.all()).get(id=m.private_milestone2.id) + m.blocked_milestone = f.MilestoneFactory(project=m.blocked_project) + m.blocked_milestone = attach_milestone_extra_info(Milestone.objects.all()).get(id=m.blocked_milestone.id) + + return m + + +def test_milestone_retrieve(client, data): + public_url = reverse('milestones-detail', kwargs={"pk": data.public_milestone.pk}) + private_url1 = reverse('milestones-detail', kwargs={"pk": data.private_milestone1.pk}) + private_url2 = reverse('milestones-detail', kwargs={"pk": data.private_milestone2.pk}) + blocked_url = reverse('milestones-detail', kwargs={"pk": data.blocked_milestone.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_milestone_update(client, data): + public_url = reverse('milestones-detail', kwargs={"pk": data.public_milestone.pk}) + private_url1 = reverse('milestones-detail', kwargs={"pk": data.private_milestone1.pk}) + private_url2 = reverse('milestones-detail', kwargs={"pk": data.private_milestone2.pk}) + blocked_url = reverse('milestones-detail', kwargs={"pk": data.blocked_milestone.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + milestone_data = MilestoneSerializer(data.public_milestone).data + milestone_data["name"] = "test" + milestone_data = json.dumps(milestone_data) + results = helper_test_http_method(client, 'put', public_url, milestone_data, users) + assert results == [401, 403, 403, 200, 200] + + milestone_data = MilestoneSerializer(data.private_milestone1).data + milestone_data["name"] = "test" + milestone_data = json.dumps(milestone_data) + results = helper_test_http_method(client, 'put', private_url1, milestone_data, users) + assert results == [401, 403, 403, 200, 200] + + milestone_data = MilestoneSerializer(data.private_milestone2).data + milestone_data["name"] = "test" + milestone_data = json.dumps(milestone_data) + results = helper_test_http_method(client, 'put', private_url2, milestone_data, users) + assert results == [401, 403, 403, 200, 200] + + milestone_data = MilestoneSerializer(data.blocked_milestone).data + milestone_data["name"] = "test" + milestone_data = json.dumps(milestone_data) + results = helper_test_http_method(client, 'put', blocked_url, milestone_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_milestone_delete(client, data): + public_url = reverse('milestones-detail', kwargs={"pk": data.public_milestone.pk}) + private_url1 = reverse('milestones-detail', kwargs={"pk": data.private_milestone1.pk}) + private_url2 = reverse('milestones-detail', kwargs={"pk": data.private_milestone2.pk}) + blocked_url = reverse('milestones-detail', kwargs={"pk": data.blocked_milestone.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_milestone_list(client, data): + url = reverse('milestones-list') + + response = client.get(url) + milestones_data = json.loads(response.content.decode('utf-8')) + assert len(milestones_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + milestones_data = json.loads(response.content.decode('utf-8')) + assert len(milestones_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + milestones_data = json.loads(response.content.decode('utf-8')) + assert len(milestones_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + milestones_data = json.loads(response.content.decode('utf-8')) + assert len(milestones_data) == 4 + assert response.status_code == 200 + + +def test_milestone_create(client, data): + url = reverse('milestones-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({ + "name": "test", + "estimated_start": "2014-12-10", + "estimated_finish": "2014-12-24", + "project": data.public_project.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, + lambda: Milestone.objects.all().delete()) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "name": "test", + "estimated_start": "2014-12-10", + "estimated_finish": "2014-12-24", + "project": data.private_project1.pk, + }) + + results = helper_test_http_method(client, 'post', url, create_data, users, + lambda: Milestone.objects.all().delete()) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "name": "test", + "estimated_start": "2014-12-10", + "estimated_finish": "2014-12-24", + "project": data.private_project2.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Milestone.objects.all().delete()) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "name": "test", + "estimated_start": "2014-12-10", + "estimated_finish": "2014-12-24", + "project": data.blocked_project.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Milestone.objects.all().delete()) + assert results == [401, 403, 403, 451, 451] + + +def test_milestone_patch(client, data): + public_url = reverse('milestones-detail', kwargs={"pk": data.public_milestone.pk}) + private_url1 = reverse('milestones-detail', kwargs={"pk": data.private_milestone1.pk}) + private_url2 = reverse('milestones-detail', kwargs={"pk": data.private_milestone2.pk}) + blocked_url = reverse('milestones-detail', kwargs={"pk": data.blocked_milestone.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"name": "test"}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"name": "test"}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"name": "test"}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"name": "test"}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_milestone_action_stats(client, data): + public_url = reverse('milestones-stats', kwargs={"pk": data.public_milestone.pk}) + private_url1 = reverse('milestones-stats', kwargs={"pk": data.private_milestone1.pk}) + private_url2 = reverse('milestones-stats', kwargs={"pk": data.private_milestone2.pk}) + blocked_url = reverse('milestones-stats', kwargs={"pk": data.blocked_milestone.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_milestone_action_watch(client, data): + public_url = reverse('milestones-watch', kwargs={"pk": data.public_milestone.pk}) + private_url1 = reverse('milestones-watch', kwargs={"pk": data.private_milestone1.pk}) + private_url2 = reverse('milestones-watch', kwargs={"pk": data.private_milestone2.pk}) + blocked_url = reverse('milestones-watch', kwargs={"pk": data.blocked_milestone.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_milestone_action_unwatch(client, data): + public_url = reverse('milestones-unwatch', kwargs={"pk": data.public_milestone.pk}) + private_url1 = reverse('milestones-unwatch', kwargs={"pk": data.private_milestone1.pk}) + private_url2 = reverse('milestones-unwatch', kwargs={"pk": data.private_milestone2.pk}) + blocked_url = reverse('milestones-unwatch', kwargs={"pk": data.blocked_milestone.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_milestone_watchers_list(client, data): + public_url = reverse('milestone-watchers-list', kwargs={"resource_id": data.public_milestone.pk}) + private_url1 = reverse('milestone-watchers-list', kwargs={"resource_id": data.private_milestone1.pk}) + private_url2 = reverse('milestone-watchers-list', kwargs={"resource_id": data.private_milestone2.pk}) + blocked_url = reverse('milestone-watchers-list', kwargs={"resource_id": data.blocked_milestone.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_milestone_watchers_retrieve(client, data): + add_watcher(data.public_milestone, data.project_owner) + public_url = reverse('milestone-watchers-detail', kwargs={"resource_id": data.public_milestone.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_milestone1, data.project_owner) + private_url1 = reverse('milestone-watchers-detail', kwargs={"resource_id": data.private_milestone1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_milestone2, data.project_owner) + private_url2 = reverse('milestone-watchers-detail', kwargs={"resource_id": data.private_milestone2.pk, + "pk": data.project_owner.pk}) + add_watcher(data.blocked_milestone, data.project_owner) + blocked_url = reverse('milestone-watchers-detail', kwargs={"resource_id": data.blocked_milestone.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_modules_resources.py b/tests/integration/resources_permissions/test_modules_resources.py new file mode 100644 index 000000000..c7a432be1 --- /dev/null +++ b/tests/integration/resources_permissions/test_modules_resources.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.urls import reverse + +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.base.utils import json + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals +from taiga.projects import choices as project_choices +from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher +from taiga.projects.occ import OCCResourceMixin + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + return m + + +def test_modules_retrieve(client, data): + public_url = reverse('projects-modules', kwargs={"pk": data.public_project.pk}) + private_url1 = reverse('projects-modules', kwargs={"pk": data.private_project1.pk}) + private_url2 = reverse('projects-modules', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-modules', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 404, 404, 403, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 404, 404, 403, 200] + + +def test_modules_update(client, data): + public_url = reverse('projects-modules', kwargs={"pk": data.public_project.pk}) + private_url1 = reverse('projects-modules', kwargs={"pk": data.private_project1.pk}) + private_url2 = reverse('projects-modules', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-modules', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + results = helper_test_http_method(client, 'put', public_url, {"att": "test"}, users) + assert results == [405, 405, 405, 405, 405] + + results = helper_test_http_method(client, 'put', private_url1, {"att": "test"}, users) + assert results == [405, 405, 405, 405, 405] + + results = helper_test_http_method(client, 'put', private_url2, {"att": "test"}, users) + assert results == [405, 405, 405, 405, 405] + + results = helper_test_http_method(client, 'put', blocked_url, {"att": "test"}, users) + assert results == [405, 405, 405, 405, 405] + + +def test_modules_delete(client, data): + public_url = reverse('projects-modules', kwargs={"pk": data.public_project.pk}) + private_url1 = reverse('projects-modules', kwargs={"pk": data.private_project1.pk}) + private_url2 = reverse('projects-modules', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-modules', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [405, 405, 405, 405] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [405, 405, 405, 405] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [405, 405, 405, 405] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [405, 405, 405, 405] + + +def test_modules_patch(client, data): + public_url = reverse('projects-modules', kwargs={"pk": data.public_project.pk}) + private_url1 = reverse('projects-modules', kwargs={"pk": data.private_project1.pk}) + private_url2 = reverse('projects-modules', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-modules', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"att": "test"}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 403, 204] + + patch_data = json.dumps({"att": "test"}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 403, 204] + + patch_data = json.dumps({"att": "test"}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 404, 404, 403, 204] + + patch_data = json.dumps({"att": "test"}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 404, 404, 403, 451] diff --git a/tests/integration/resources_permissions/test_projects_choices_resources.py b/tests/integration/resources_permissions/test_projects_choices_resources.py new file mode 100644 index 000000000..1c35cdef4 --- /dev/null +++ b/tests/integration/resources_permissions/test_projects_choices_resources.py @@ -0,0 +1,2701 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects import serializers +from taiga.users.serializers import RoleSerializer +from taiga.permissions.choices import MEMBERS_PERMISSIONS + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=['view_project'], + public_permissions=['view_project'], + owner=m.project_owner, + tags_colors = [("tag1", "#123123"), ("tag2", "#456456"), ("tag3", "#111222")]) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=['view_project'], + public_permissions=['view_project'], + owner=m.project_owner, + tags_colors = [("tag1", "#123123"), ("tag2", "#456456"), ("tag3", "#111222")]) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + tags_colors = [("tag1", "#123123"), ("tag2", "#456456"), ("tag3", "#111222")]) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF, + tags_colors = [("tag1", "#123123"), ("tag2", "#456456"), ("tag3", "#111222")]) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_epic_status = f.EpicStatusFactory(project=m.public_project) + m.public_epic_status_aux = f.EpicStatusFactory(project=m.public_project) + m.private_epic_status1 = f.EpicStatusFactory(project=m.private_project1) + m.private_epic_status1_aux = f.EpicStatusFactory(project=m.private_project1) + m.private_epic_status2 = f.EpicStatusFactory(project=m.private_project2) + m.private_epic_status2_aux = f.EpicStatusFactory(project=m.private_project2) + m.blocked_epic_status = f.EpicStatusFactory(project=m.blocked_project) + m.blocked_epic_status_aux = f.EpicStatusFactory(project=m.blocked_project) + + m.public_points = f.PointsFactory(project=m.public_project) + m.public_points_aux = f.PointsFactory(project=m.public_project) + m.private_points1 = f.PointsFactory(project=m.private_project1) + m.private_points1_aux = f.PointsFactory(project=m.private_project1) + m.private_points2 = f.PointsFactory(project=m.private_project2) + m.private_points2_aux = f.PointsFactory(project=m.private_project2) + m.blocked_points = f.PointsFactory(project=m.blocked_project) + m.blocked_points_aux = f.PointsFactory(project=m.blocked_project) + + m.public_user_story_status = f.UserStoryStatusFactory(project=m.public_project) + m.public_user_story_status_aux = f.UserStoryStatusFactory(project=m.public_project) + m.private_user_story_status1 = f.UserStoryStatusFactory(project=m.private_project1) + m.private_user_story_status1_aux = f.UserStoryStatusFactory(project=m.private_project1) + m.private_user_story_status2 = f.UserStoryStatusFactory(project=m.private_project2) + m.private_user_story_status2_aux = f.UserStoryStatusFactory(project=m.private_project2) + m.blocked_user_story_status = f.UserStoryStatusFactory(project=m.blocked_project) + m.blocked_user_story_status_aux = f.UserStoryStatusFactory(project=m.blocked_project) + + m.public_swimlane = f.SwimlaneFactory(project=m.public_project) + m.private_swimlane1 = f.SwimlaneFactory(project=m.private_project1) + m.private_swimlane2 = f.SwimlaneFactory(project=m.private_project2) + m.blocked_swimlane = f.SwimlaneFactory(project=m.blocked_project) + + m.public_swimlane_user_story_status = m.public_swimlane.statuses.all().first() + m.private_swimlane_user_story_status1 = m.private_swimlane1.statuses.all().first() + m.private_swimlane_user_story_status2 = m.private_swimlane2.statuses.all().first() + m.blocked_swimlane_user_story_status = m.blocked_swimlane.statuses.all().first() + + m.public_task_status = f.TaskStatusFactory(project=m.public_project) + m.public_task_status_aux = f.TaskStatusFactory(project=m.public_project) + m.private_task_status1 = f.TaskStatusFactory(project=m.private_project1) + m.private_task_status1_aux = f.TaskStatusFactory(project=m.private_project1) + m.private_task_status2 = f.TaskStatusFactory(project=m.private_project2) + m.private_task_status2_aux = f.TaskStatusFactory(project=m.private_project2) + m.blocked_task_status = f.TaskStatusFactory(project=m.blocked_project) + m.blocked_task_status_aux = f.TaskStatusFactory(project=m.blocked_project) + + m.public_issue_status = f.IssueStatusFactory(project=m.public_project) + m.public_issue_status_aux = f.IssueStatusFactory(project=m.public_project) + m.private_issue_status1 = f.IssueStatusFactory(project=m.private_project1) + m.private_issue_status1_aux = f.IssueStatusFactory(project=m.private_project1) + m.private_issue_status2 = f.IssueStatusFactory(project=m.private_project2) + m.private_issue_status2_aux = f.IssueStatusFactory(project=m.private_project2) + m.blocked_issue_status = f.IssueStatusFactory(project=m.blocked_project) + m.blocked_issue_status_aux = f.IssueStatusFactory(project=m.blocked_project) + + m.public_issue_type = f.IssueTypeFactory(project=m.public_project) + m.public_issue_type_aux = f.IssueTypeFactory(project=m.public_project) + m.private_issue_type1 = f.IssueTypeFactory(project=m.private_project1) + m.private_issue_type1_aux = f.IssueTypeFactory(project=m.private_project1) + m.private_issue_type2 = f.IssueTypeFactory(project=m.private_project2) + m.private_issue_type2_aux = f.IssueTypeFactory(project=m.private_project2) + m.blocked_issue_type = f.IssueTypeFactory(project=m.blocked_project) + m.blocked_issue_type_aux = f.IssueTypeFactory(project=m.blocked_project) + + m.public_priority = f.PriorityFactory(project=m.public_project) + m.public_priority_aux = f.PriorityFactory(project=m.public_project) + m.private_priority1 = f.PriorityFactory(project=m.private_project1) + m.private_priority1_aux = f.PriorityFactory(project=m.private_project1) + m.private_priority2 = f.PriorityFactory(project=m.private_project2) + m.private_priority2_aux = f.PriorityFactory(project=m.private_project2) + m.blocked_priority = f.PriorityFactory(project=m.blocked_project) + m.blocked_priority_aux = f.PriorityFactory(project=m.blocked_project) + + m.public_severity = f.SeverityFactory(project=m.public_project) + m.public_severity_aux = f.SeverityFactory(project=m.public_project) + m.private_severity1 = f.SeverityFactory(project=m.private_project1) + m.private_severity1_aux = f.SeverityFactory(project=m.private_project1) + m.private_severity2 = f.SeverityFactory(project=m.private_project2) + m.private_severity2_aux = f.SeverityFactory(project=m.private_project2) + m.blocked_severity = f.SeverityFactory(project=m.blocked_project) + m.blocked_severity_aux = f.SeverityFactory(project=m.blocked_project) + + m.project_template = m.public_project.creation_template + + return m + + +##################################################### +# Roles +##################################################### + +def test_roles_retrieve(client, data): + public_url = reverse('roles-detail', kwargs={"pk": data.public_project.roles.all()[0].pk}) + private1_url = reverse('roles-detail', kwargs={"pk": data.private_project1.roles.all()[0].pk}) + private2_url = reverse('roles-detail', kwargs={"pk": data.private_project2.roles.all()[0].pk}) + blocked_url = reverse('roles-detail', kwargs={"pk": data.blocked_project.roles.all()[0].pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_roles_update(client, data): + public_url = reverse('roles-detail', kwargs={"pk": data.public_project.roles.all()[0].pk}) + private1_url = reverse('roles-detail', kwargs={"pk": data.private_project1.roles.all()[0].pk}) + private2_url = reverse('roles-detail', kwargs={"pk": data.private_project2.roles.all()[0].pk}) + blocked_url = reverse('roles-detail', kwargs={"pk": data.blocked_project.roles.all()[0].pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + role_data = RoleSerializer(data.public_project.roles.all()[0]).data + role_data["name"] = "test" + role_data = json.dumps(role_data) + results = helper_test_http_method(client, 'put', public_url, role_data, users) + assert results == [401, 403, 403, 403, 200] + + role_data = RoleSerializer(data.private_project1.roles.all()[0]).data + role_data["name"] = "test" + role_data = json.dumps(role_data) + results = helper_test_http_method(client, 'put', private1_url, role_data, users) + assert results == [401, 403, 403, 403, 200] + + role_data = RoleSerializer(data.private_project2.roles.all()[0]).data + role_data["name"] = "test" + role_data = json.dumps(role_data) + results = helper_test_http_method(client, 'put', private2_url, role_data, users) + assert results == [401, 403, 403, 403, 200] + + role_data = RoleSerializer(data.blocked_project.roles.all()[0]).data + role_data["name"] = "test" + role_data = json.dumps(role_data) + results = helper_test_http_method(client, 'put', blocked_url, role_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_roles_delete(client, data): + public_url = reverse('roles-detail', kwargs={"pk": data.public_project.roles.all()[0].pk}) + private1_url = reverse('roles-detail', kwargs={"pk": data.private_project1.roles.all()[0].pk}) + private2_url = reverse('roles-detail', kwargs={"pk": data.private_project2.roles.all()[0].pk}) + blocked_url = reverse('roles-detail', kwargs={"pk": data.blocked_project.roles.all()[0].pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_roles_list(client, data): + url = reverse('roles-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 3 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 3 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 3 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 7 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 7 + assert response.status_code == 200 + + +def test_roles_patch(client, data): + public_url = reverse('roles-detail', kwargs={"pk": data.public_project.roles.all()[0].pk}) + private1_url = reverse('roles-detail', kwargs={"pk": data.private_project1.roles.all()[0].pk}) + private2_url = reverse('roles-detail', kwargs={"pk": data.private_project2.roles.all()[0].pk}) + blocked_url = reverse('roles-detail', kwargs={"pk": data.blocked_project.roles.all()[0].pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Epic Status +##################################################### + +def test_epic_status_retrieve(client, data): + public_url = reverse('epic-statuses-detail', kwargs={"pk": data.public_epic_status.pk}) + private1_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status1.pk}) + private2_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status2.pk}) + blocked_url = reverse('epic-statuses-detail', kwargs={"pk": data.blocked_epic_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_epic_status_update(client, data): + public_url = reverse('epic-statuses-detail', kwargs={"pk": data.public_epic_status.pk}) + private1_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status1.pk}) + private2_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status2.pk}) + blocked_url = reverse('epic-statuses-detail', kwargs={"pk": data.blocked_epic_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + epic_status_data = serializers.EpicStatusSerializer(data.public_epic_status).data + epic_status_data["name"] = "test" + epic_status_data = json.dumps(epic_status_data) + results = helper_test_http_method(client, 'put', public_url, epic_status_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_status_data = serializers.EpicStatusSerializer(data.private_epic_status1).data + epic_status_data["name"] = "test" + epic_status_data = json.dumps(epic_status_data) + results = helper_test_http_method(client, 'put', private1_url, epic_status_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_status_data = serializers.EpicStatusSerializer(data.private_epic_status2).data + epic_status_data["name"] = "test" + epic_status_data = json.dumps(epic_status_data) + results = helper_test_http_method(client, 'put', private2_url, epic_status_data, users) + assert results == [401, 403, 403, 403, 200] + + epic_status_data = serializers.EpicStatusSerializer(data.blocked_epic_status).data + epic_status_data["name"] = "test" + epic_status_data = json.dumps(epic_status_data) + results = helper_test_http_method(client, 'put', blocked_url, epic_status_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_status_delete(client, data): + public_url = reverse('epic-statuses-detail', kwargs={"pk": data.public_epic_status.pk}) + public_url += f'?moveTo={data.public_epic_status_aux.pk}' + private1_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status1.pk}) + private1_url += f'?moveTo={data.private_epic_status1_aux.pk}' + private2_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status2.pk}) + private2_url += f'?moveTo={data.private_epic_status2_aux.pk}' + blocked_url = reverse('epic-statuses-detail', kwargs={"pk": data.blocked_epic_status.pk}) + blocked_url += f'?moveTo={data.blocked_epic_status_aux.pk}' + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_status_list(client, data): + url = reverse('epic-statuses-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + +def test_epic_status_patch(client, data): + public_url = reverse('epic-statuses-detail', kwargs={"pk": data.public_epic_status.pk}) + private1_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status1.pk}) + private2_url = reverse('epic-statuses-detail', kwargs={"pk": data.private_epic_status2.pk}) + blocked_url = reverse('epic-statuses-detail', kwargs={"pk": data.blocked_epic_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_epic_status_action_bulk_update_order(client, data): + url = reverse('epic-statuses-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_epic_statuses": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_statuses": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_statuses": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_epic_statuses": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Swimlanes +##################################################### + +def test_swimlane_retrieve(client, data): + public_url = reverse('swimlanes-detail', kwargs={"pk": data.public_swimlane.pk}) + private1_url = reverse('swimlanes-detail', kwargs={"pk": data.private_swimlane1.pk}) + private2_url = reverse('swimlanes-detail', kwargs={"pk": data.private_swimlane2.pk}) + blocked_url = reverse('swimlanes-detail', kwargs={"pk": data.blocked_swimlane.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_swimlane_update(client, data): + public_url = reverse('swimlanes-detail', kwargs={"pk": data.public_swimlane.pk}) + private1_url = reverse('swimlanes-detail', kwargs={"pk": data.private_swimlane1.pk}) + private2_url = reverse('swimlanes-detail', kwargs={"pk": data.private_swimlane2.pk}) + blocked_url = reverse('swimlanes-detail', kwargs={"pk": data.blocked_swimlane.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + swimlane_data = serializers.SwimlaneSerializer(data.public_swimlane).data + swimlane_data["name"] = "test" + swimlane_data = json.dumps(swimlane_data) + results = helper_test_http_method(client, 'put', public_url, swimlane_data, users) + assert results == [401, 403, 403, 403, 200] + + swimlane_data = serializers.SwimlaneSerializer(data.private_swimlane1).data + swimlane_data["name"] = "test" + swimlane_data = json.dumps(swimlane_data) + results = helper_test_http_method(client, 'put', private1_url, swimlane_data, users) + assert results == [401, 403, 403, 403, 200] + + swimlane_data = serializers.SwimlaneSerializer(data.private_swimlane2).data + swimlane_data["name"] = "test" + swimlane_data = json.dumps(swimlane_data) + results = helper_test_http_method(client, 'put', private2_url, swimlane_data, users) + assert results == [401, 403, 403, 403, 200] + + swimlane_data = serializers.SwimlaneSerializer(data.blocked_swimlane).data + swimlane_data["name"] = "test" + swimlane_data = json.dumps(swimlane_data) + results = helper_test_http_method(client, 'put', blocked_url, swimlane_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_swimlane_delete(client, data): + public_url = reverse('swimlanes-detail', kwargs={"pk": data.public_swimlane.pk}) + private1_url = reverse('swimlanes-detail', kwargs={"pk": data.private_swimlane1.pk}) + private2_url = reverse('swimlanes-detail', kwargs={"pk": data.private_swimlane2.pk}) + blocked_url = reverse('swimlanes-detail', kwargs={"pk": data.blocked_swimlane.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_swimlane_list(client, data): + url = reverse('swimlanes-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + +def test_swimlane_patch(client, data): + public_url = reverse('swimlanes-detail', kwargs={"pk": data.public_swimlane.pk}) + private1_url = reverse('swimlanes-detail', kwargs={"pk": data.private_swimlane1.pk}) + private2_url = reverse('swimlanes-detail', kwargs={"pk": data.private_swimlane2.pk}) + blocked_url = reverse('swimlanes-detail', kwargs={"pk": data.blocked_swimlane.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_swimlane_action_bulk_update_order(client, data): + url = reverse('swimlanes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_swimlanes": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_swimlanes": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_swimlanes": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_swimlanes": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + +##################################################### +# Swimlane User Story Status +##################################################### + +def test_swimlane_user_story_status_retrieve(client, data): + public_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.public_swimlane_user_story_status.pk}) + private1_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.private_swimlane_user_story_status1.pk}) + private2_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.private_swimlane_user_story_status2.pk}) + blocked_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.blocked_swimlane_user_story_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_swimlane_user_story_status_update(client, data): + public_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.public_swimlane_user_story_status.pk}) + private1_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.private_swimlane_user_story_status1.pk}) + private2_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.private_swimlane_user_story_status2.pk}) + blocked_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.blocked_swimlane_user_story_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + swimlane_user_story_status_data = serializers.SwimlaneUserStoryStatusSerializer(data.public_swimlane_user_story_status).data + swimlane_user_story_status_data["wip_limit"] = 2 + swimlane_user_story_status_data = json.dumps(swimlane_user_story_status_data) + results = helper_test_http_method(client, 'put', public_url, swimlane_user_story_status_data, users) + assert results == [401, 403, 403, 403, 200] + + swimlane_user_story_status_data = serializers.SwimlaneUserStoryStatusSerializer(data.private_swimlane_user_story_status1).data + swimlane_user_story_status_data["wip_limit"] = 2 + swimlane_user_story_status_data = json.dumps(swimlane_user_story_status_data) + results = helper_test_http_method(client, 'put', private1_url, swimlane_user_story_status_data, users) + assert results == [401, 403, 403, 403, 200] + + swimlane_user_story_status_data = serializers.SwimlaneUserStoryStatusSerializer(data.private_swimlane_user_story_status2).data + swimlane_user_story_status_data["wip_limit"] = 2 + swimlane_user_story_status_data = json.dumps(swimlane_user_story_status_data) + results = helper_test_http_method(client, 'put', private2_url, swimlane_user_story_status_data, users) + assert results == [401, 403, 403, 403, 200] + + swimlane_user_story_status_data = serializers.SwimlaneUserStoryStatusSerializer(data.blocked_swimlane_user_story_status).data + swimlane_user_story_status_data["wip_limit"] = 2 + swimlane_user_story_status_data = json.dumps(swimlane_user_story_status_data) + results = helper_test_http_method(client, 'put', blocked_url, swimlane_user_story_status_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_swimlane_user_story_status_delete(client, data): + public_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.public_swimlane_user_story_status.pk}) + private1_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.private_swimlane_user_story_status1.pk}) + private2_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.private_swimlane_user_story_status2.pk}) + blocked_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.blocked_swimlane_user_story_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [405, 405, 405, 405, 405] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [405, 405, 405, 405, 405] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [405, 405, 405, 405, 405] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [405, 405, 405, 405, 405] + + +def test_swimlane_user_story_status_list(client, data): + url = reverse('swimlane-userstory-statuses-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + +def test_swimlane_user_story_status_patch(client, data): + public_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.public_swimlane_user_story_status.pk}) + private1_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.private_swimlane_user_story_status1.pk}) + private2_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.private_swimlane_user_story_status2.pk}) + blocked_url = reverse('swimlane-userstory-statuses-detail', kwargs={"pk": data.blocked_swimlane_user_story_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"wip_limit": 42}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"wip_limit": 42}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"wip_limit": 42}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"wip_limit": 42}', users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Points +##################################################### + +def test_points_retrieve(client, data): + public_url = reverse('points-detail', kwargs={"pk": data.public_points.pk}) + private1_url = reverse('points-detail', kwargs={"pk": data.private_points1.pk}) + private2_url = reverse('points-detail', kwargs={"pk": data.private_points2.pk}) + blocked_url = reverse('points-detail', kwargs={"pk": data.blocked_points.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_points_update(client, data): + public_url = reverse('points-detail', kwargs={"pk": data.public_points.pk}) + private1_url = reverse('points-detail', kwargs={"pk": data.private_points1.pk}) + private2_url = reverse('points-detail', kwargs={"pk": data.private_points2.pk}) + blocked_url = reverse('points-detail', kwargs={"pk": data.blocked_points.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + points_data = serializers.PointsSerializer(data.public_points).data + points_data["name"] = "test" + points_data = json.dumps(points_data) + results = helper_test_http_method(client, 'put', public_url, points_data, users) + assert results == [401, 403, 403, 403, 200] + + points_data = serializers.PointsSerializer(data.private_points1).data + points_data["name"] = "test" + points_data = json.dumps(points_data) + results = helper_test_http_method(client, 'put', private1_url, points_data, users) + assert results == [401, 403, 403, 403, 200] + + points_data = serializers.PointsSerializer(data.private_points2).data + points_data["name"] = "test" + points_data = json.dumps(points_data) + results = helper_test_http_method(client, 'put', private2_url, points_data, users) + assert results == [401, 403, 403, 403, 200] + + points_data = serializers.PointsSerializer(data.blocked_points).data + points_data["name"] = "test" + points_data = json.dumps(points_data) + results = helper_test_http_method(client, 'put', blocked_url, points_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_points_delete(client, data): + public_url = reverse('points-detail', kwargs={"pk": data.public_points.pk}) + public_url += f'?moveTo={data.public_points_aux.pk}' + private1_url = reverse('points-detail', kwargs={"pk": data.private_points1.pk}) + private1_url += f'?moveTo={data.private_points1_aux.pk}' + private2_url = reverse('points-detail', kwargs={"pk": data.private_points2.pk}) + private2_url += f'?moveTo={data.private_points2_aux.pk}' + blocked_url = reverse('points-detail', kwargs={"pk": data.blocked_points.pk}) + blocked_url += f'?moveTo={data.blocked_points_aux.pk}' + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_points_list(client, data): + url = reverse('points-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + +def test_points_patch(client, data): + public_url = reverse('points-detail', kwargs={"pk": data.public_points.pk}) + private1_url = reverse('points-detail', kwargs={"pk": data.private_points1.pk}) + private2_url = reverse('points-detail', kwargs={"pk": data.private_points2.pk}) + blocked_url = reverse('points-detail', kwargs={"pk": data.blocked_points.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_points_action_bulk_update_order(client, data): + url = reverse('points-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_points": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_points": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_points": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_points": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# User Story Status +##################################################### + +def test_user_story_status_retrieve(client, data): + public_url = reverse('userstory-statuses-detail', kwargs={"pk": data.public_user_story_status.pk}) + private1_url = reverse('userstory-statuses-detail', kwargs={"pk": data.private_user_story_status1.pk}) + private2_url = reverse('userstory-statuses-detail', kwargs={"pk": data.private_user_story_status2.pk}) + blocked_url = reverse('userstory-statuses-detail', kwargs={"pk": data.blocked_user_story_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_user_story_status_update(client, data): + public_url = reverse('userstory-statuses-detail', kwargs={"pk": data.public_user_story_status.pk}) + private1_url = reverse('userstory-statuses-detail', kwargs={"pk": data.private_user_story_status1.pk}) + private2_url = reverse('userstory-statuses-detail', kwargs={"pk": data.private_user_story_status2.pk}) + blocked_url = reverse('userstory-statuses-detail', kwargs={"pk": data.blocked_user_story_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + user_story_status_data = serializers.UserStoryStatusSerializer(data.public_user_story_status).data + user_story_status_data["name"] = "test" + user_story_status_data = json.dumps(user_story_status_data) + results = helper_test_http_method(client, 'put', public_url, user_story_status_data, users) + assert results == [401, 403, 403, 403, 200] + + user_story_status_data = serializers.UserStoryStatusSerializer(data.private_user_story_status1).data + user_story_status_data["name"] = "test" + user_story_status_data = json.dumps(user_story_status_data) + results = helper_test_http_method(client, 'put', private1_url, user_story_status_data, users) + assert results == [401, 403, 403, 403, 200] + + user_story_status_data = serializers.UserStoryStatusSerializer(data.private_user_story_status2).data + user_story_status_data["name"] = "test" + user_story_status_data = json.dumps(user_story_status_data) + results = helper_test_http_method(client, 'put', private2_url, user_story_status_data, users) + assert results == [401, 403, 403, 403, 200] + + user_story_status_data = serializers.UserStoryStatusSerializer(data.blocked_user_story_status).data + user_story_status_data["name"] = "test" + user_story_status_data = json.dumps(user_story_status_data) + results = helper_test_http_method(client, 'put', blocked_url, user_story_status_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_user_story_status_delete(client, data): + public_url = reverse('userstory-statuses-detail', kwargs={"pk": data.public_user_story_status.pk}) + public_url += f'?moveTo={data.public_user_story_status_aux.pk}' + private1_url = reverse('userstory-statuses-detail', kwargs={"pk": data.private_user_story_status1.pk}) + private1_url += f'?moveTo={data.private_user_story_status1_aux.pk}' + private2_url = reverse('userstory-statuses-detail', kwargs={"pk": data.private_user_story_status2.pk}) + private2_url += f'?moveTo={data.private_user_story_status2_aux.pk}' + blocked_url = reverse('userstory-statuses-detail', kwargs={"pk": data.blocked_user_story_status.pk}) + blocked_url += f'?moveTo={data.blocked_user_story_status_aux.pk}' + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_user_story_status_list(client, data): + url = reverse('userstory-statuses-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + +def test_user_story_status_patch(client, data): + public_url = reverse('userstory-statuses-detail', kwargs={"pk": data.public_user_story_status.pk}) + private1_url = reverse('userstory-statuses-detail', kwargs={"pk": data.private_user_story_status1.pk}) + private2_url = reverse('userstory-statuses-detail', kwargs={"pk": data.private_user_story_status2.pk}) + blocked_url = reverse('userstory-statuses-detail', kwargs={"pk": data.blocked_user_story_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_user_story_status_action_bulk_update_order(client, data): + url = reverse('userstory-statuses-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_userstory_statuses": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_statuses": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_statuses": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_statuses": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Task Status +##################################################### + +def test_task_status_retrieve(client, data): + public_url = reverse('task-statuses-detail', kwargs={"pk": data.public_task_status.pk}) + private1_url = reverse('task-statuses-detail', kwargs={"pk": data.private_task_status1.pk}) + private2_url = reverse('task-statuses-detail', kwargs={"pk": data.private_task_status2.pk}) + blocked_url = reverse('task-statuses-detail', kwargs={"pk": data.blocked_task_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_status_update(client, data): + public_url = reverse('task-statuses-detail', kwargs={"pk": data.public_task_status.pk}) + private1_url = reverse('task-statuses-detail', kwargs={"pk": data.private_task_status1.pk}) + private2_url = reverse('task-statuses-detail', kwargs={"pk": data.private_task_status2.pk}) + blocked_url = reverse('task-statuses-detail', kwargs={"pk": data.blocked_task_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_status_data = serializers.TaskStatusSerializer(data.public_task_status).data + task_status_data["name"] = "test" + task_status_data = json.dumps(task_status_data) + results = helper_test_http_method(client, 'put', public_url, task_status_data, users) + assert results == [401, 403, 403, 403, 200] + + task_status_data = serializers.TaskStatusSerializer(data.private_task_status1).data + task_status_data["name"] = "test" + task_status_data = json.dumps(task_status_data) + results = helper_test_http_method(client, 'put', private1_url, task_status_data, users) + assert results == [401, 403, 403, 403, 200] + + task_status_data = serializers.TaskStatusSerializer(data.private_task_status2).data + task_status_data["name"] = "test" + task_status_data = json.dumps(task_status_data) + results = helper_test_http_method(client, 'put', private2_url, task_status_data, users) + assert results == [401, 403, 403, 403, 200] + + task_status_data = serializers.TaskStatusSerializer(data.blocked_task_status).data + task_status_data["name"] = "test" + task_status_data = json.dumps(task_status_data) + results = helper_test_http_method(client, 'put', blocked_url, task_status_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_task_status_delete(client, data): + public_url = reverse('task-statuses-detail', kwargs={"pk": data.public_task_status.pk}) + public_url += f'?moveTo={data.public_task_status_aux.pk}' + private1_url = reverse('task-statuses-detail', kwargs={"pk": data.private_task_status1.pk}) + private1_url += f'?moveTo={data.private_task_status1_aux.pk}' + private2_url = reverse('task-statuses-detail', kwargs={"pk": data.private_task_status2.pk}) + private2_url += f'?moveTo={data.private_task_status2_aux.pk}' + blocked_url = reverse('task-statuses-detail', kwargs={"pk": data.blocked_task_status.pk}) + blocked_url += f'?moveTo={data.blocked_task_status_aux.pk}' + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_task_status_list(client, data): + url = reverse('task-statuses-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + +def test_task_status_patch(client, data): + public_url = reverse('task-statuses-detail', kwargs={"pk": data.public_task_status.pk}) + private1_url = reverse('task-statuses-detail', kwargs={"pk": data.private_task_status1.pk}) + private2_url = reverse('task-statuses-detail', kwargs={"pk": data.private_task_status2.pk}) + blocked_url = reverse('task-statuses-detail', kwargs={"pk": data.blocked_task_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_task_status_action_bulk_update_order(client, data): + url = reverse('task-statuses-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_task_statuses": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_statuses": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_statuses": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_statuses": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Issue Status +##################################################### + +def test_issue_status_retrieve(client, data): + public_url = reverse('issue-statuses-detail', kwargs={"pk": data.public_issue_status.pk}) + private1_url = reverse('issue-statuses-detail', kwargs={"pk": data.private_issue_status1.pk}) + private2_url = reverse('issue-statuses-detail', kwargs={"pk": data.private_issue_status2.pk}) + blocked_url = reverse('issue-statuses-detail', kwargs={"pk": data.blocked_issue_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_status_update(client, data): + public_url = reverse('issue-statuses-detail', kwargs={"pk": data.public_issue_status.pk}) + private1_url = reverse('issue-statuses-detail', kwargs={"pk": data.private_issue_status1.pk}) + private2_url = reverse('issue-statuses-detail', kwargs={"pk": data.private_issue_status2.pk}) + blocked_url = reverse('issue-statuses-detail', kwargs={"pk": data.blocked_issue_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_status_data = serializers.IssueStatusSerializer(data.public_issue_status).data + issue_status_data["name"] = "test" + issue_status_data = json.dumps(issue_status_data) + results = helper_test_http_method(client, 'put', public_url, issue_status_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_status_data = serializers.IssueStatusSerializer(data.private_issue_status1).data + issue_status_data["name"] = "test" + issue_status_data = json.dumps(issue_status_data) + results = helper_test_http_method(client, 'put', private1_url, issue_status_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_status_data = serializers.IssueStatusSerializer(data.private_issue_status2).data + issue_status_data["name"] = "test" + issue_status_data = json.dumps(issue_status_data) + results = helper_test_http_method(client, 'put', private2_url, issue_status_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_status_data = serializers.IssueStatusSerializer(data.blocked_issue_status).data + issue_status_data["name"] = "test" + issue_status_data = json.dumps(issue_status_data) + results = helper_test_http_method(client, 'put', blocked_url, issue_status_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_status_delete(client, data): + public_url = reverse('issue-statuses-detail', kwargs={"pk": data.public_issue_status.pk}) + public_url += f'?moveTo={data.public_issue_status_aux.pk}' + private1_url = reverse('issue-statuses-detail', kwargs={"pk": data.private_issue_status1.pk}) + private1_url += f'?moveTo={data.private_issue_status1_aux.pk}' + private2_url = reverse('issue-statuses-detail', kwargs={"pk": data.private_issue_status2.pk}) + private2_url += f'?moveTo={data.private_issue_status2_aux.pk}' + blocked_url = reverse('issue-statuses-detail', kwargs={"pk": data.blocked_issue_status.pk}) + blocked_url += f'?moveTo={data.blocked_issue_status_aux.pk}' + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_status_list(client, data): + url = reverse('issue-statuses-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + +def test_issue_status_patch(client, data): + public_url = reverse('issue-statuses-detail', kwargs={"pk": data.public_issue_status.pk}) + private1_url = reverse('issue-statuses-detail', kwargs={"pk": data.private_issue_status1.pk}) + private2_url = reverse('issue-statuses-detail', kwargs={"pk": data.private_issue_status2.pk}) + blocked_url = reverse('issue-statuses-detail', kwargs={"pk": data.blocked_issue_status.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_status_action_bulk_update_order(client, data): + url = reverse('issue-statuses-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_issue_statuses": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_statuses": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_statuses": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_statuses": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Issue Type +##################################################### + +def test_issue_type_retrieve(client, data): + public_url = reverse('issue-types-detail', kwargs={"pk": data.public_issue_type.pk}) + private1_url = reverse('issue-types-detail', kwargs={"pk": data.private_issue_type1.pk}) + private2_url = reverse('issue-types-detail', kwargs={"pk": data.private_issue_type2.pk}) + blocked_url = reverse('issue-types-detail', kwargs={"pk": data.blocked_issue_type.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_type_update(client, data): + public_url = reverse('issue-types-detail', kwargs={"pk": data.public_issue_type.pk}) + private1_url = reverse('issue-types-detail', kwargs={"pk": data.private_issue_type1.pk}) + private2_url = reverse('issue-types-detail', kwargs={"pk": data.private_issue_type2.pk}) + blocked_url = reverse('issue-types-detail', kwargs={"pk": data.blocked_issue_type.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_type_data = serializers.IssueTypeSerializer(data.public_issue_type).data + issue_type_data["name"] = "test" + issue_type_data = json.dumps(issue_type_data) + results = helper_test_http_method(client, 'put', public_url, issue_type_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_type_data = serializers.IssueTypeSerializer(data.private_issue_type1).data + issue_type_data["name"] = "test" + issue_type_data = json.dumps(issue_type_data) + results = helper_test_http_method(client, 'put', private1_url, issue_type_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_type_data = serializers.IssueTypeSerializer(data.private_issue_type2).data + issue_type_data["name"] = "test" + issue_type_data = json.dumps(issue_type_data) + results = helper_test_http_method(client, 'put', private2_url, issue_type_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_type_data = serializers.IssueTypeSerializer(data.blocked_issue_type).data + issue_type_data["name"] = "test" + issue_type_data = json.dumps(issue_type_data) + results = helper_test_http_method(client, 'put', blocked_url, issue_type_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_type_delete(client, data): + public_url = reverse('issue-types-detail', kwargs={"pk": data.public_issue_type.pk}) + public_url += f'?moveTo={data.public_issue_type_aux.pk}' + private1_url = reverse('issue-types-detail', kwargs={"pk": data.private_issue_type1.pk}) + private1_url += f'?moveTo={data.private_issue_type1_aux.pk}' + private2_url = reverse('issue-types-detail', kwargs={"pk": data.private_issue_type2.pk}) + private2_url += f'?moveTo={data.private_issue_type2_aux.pk}' + blocked_url = reverse('issue-types-detail', kwargs={"pk": data.blocked_issue_type.pk}) + blocked_url += f'?moveTo={data.blocked_issue_type_aux.pk}' + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_type_list(client, data): + url = reverse('issue-types-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + +def test_issue_type_patch(client, data): + public_url = reverse('issue-types-detail', kwargs={"pk": data.public_issue_type.pk}) + private1_url = reverse('issue-types-detail', kwargs={"pk": data.private_issue_type1.pk}) + private2_url = reverse('issue-types-detail', kwargs={"pk": data.private_issue_type2.pk}) + blocked_url = reverse('issue-types-detail', kwargs={"pk": data.blocked_issue_type.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_issue_type_action_bulk_update_order(client, data): + url = reverse('issue-types-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_issue_types": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_types": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_types": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_types": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Priority +##################################################### + +def test_priority_retrieve(client, data): + public_url = reverse('priorities-detail', kwargs={"pk": data.public_priority.pk}) + private1_url = reverse('priorities-detail', kwargs={"pk": data.private_priority1.pk}) + private2_url = reverse('priorities-detail', kwargs={"pk": data.private_priority2.pk}) + blocked_url = reverse('priorities-detail', kwargs={"pk": data.blocked_priority.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_priority_update(client, data): + public_url = reverse('priorities-detail', kwargs={"pk": data.public_priority.pk}) + private1_url = reverse('priorities-detail', kwargs={"pk": data.private_priority1.pk}) + private2_url = reverse('priorities-detail', kwargs={"pk": data.private_priority2.pk}) + blocked_url = reverse('priorities-detail', kwargs={"pk": data.blocked_priority.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + priority_data = serializers.PrioritySerializer(data.public_priority).data + priority_data["name"] = "test" + priority_data = json.dumps(priority_data) + results = helper_test_http_method(client, 'put', public_url, priority_data, users) + assert results == [401, 403, 403, 403, 200] + + priority_data = serializers.PrioritySerializer(data.private_priority1).data + priority_data["name"] = "test" + priority_data = json.dumps(priority_data) + results = helper_test_http_method(client, 'put', private1_url, priority_data, users) + assert results == [401, 403, 403, 403, 200] + + priority_data = serializers.PrioritySerializer(data.private_priority2).data + priority_data["name"] = "test" + priority_data = json.dumps(priority_data) + results = helper_test_http_method(client, 'put', private2_url, priority_data, users) + assert results == [401, 403, 403, 403, 200] + + priority_data = serializers.PrioritySerializer(data.blocked_priority).data + priority_data["name"] = "test" + priority_data = json.dumps(priority_data) + results = helper_test_http_method(client, 'put', blocked_url, priority_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_priority_delete(client, data): + public_url = reverse('priorities-detail', kwargs={"pk": data.public_priority.pk}) + public_url += f'?moveTo={data.public_priority_aux.pk}' + private1_url = reverse('priorities-detail', kwargs={"pk": data.private_priority1.pk}) + private1_url += f'?moveTo={data.private_priority1_aux.pk}' + private2_url = reverse('priorities-detail', kwargs={"pk": data.private_priority2.pk}) + private2_url += f'?moveTo={data.private_priority2_aux.pk}' + blocked_url = reverse('priorities-detail', kwargs={"pk": data.blocked_priority.pk}) + blocked_url += f'?moveTo={data.blocked_priority_aux.pk}' + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_priority_list(client, data): + url = reverse('priorities-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + +def test_priority_patch(client, data): + public_url = reverse('priorities-detail', kwargs={"pk": data.public_priority.pk}) + private1_url = reverse('priorities-detail', kwargs={"pk": data.private_priority1.pk}) + private2_url = reverse('priorities-detail', kwargs={"pk": data.private_priority2.pk}) + blocked_url = reverse('priorities-detail', kwargs={"pk": data.blocked_priority.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_priority_action_bulk_update_order(client, data): + url = reverse('priorities-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_priorities": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_priorities": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_priorities": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_priorities": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Severity +##################################################### + +def test_severity_retrieve(client, data): + public_url = reverse('severities-detail', kwargs={"pk": data.public_severity.pk}) + private1_url = reverse('severities-detail', kwargs={"pk": data.private_severity1.pk}) + private2_url = reverse('severities-detail', kwargs={"pk": data.private_severity2.pk}) + blocked_url = reverse('severities-detail', kwargs={"pk": data.blocked_severity.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_severity_update(client, data): + public_url = reverse('severities-detail', kwargs={"pk": data.public_severity.pk}) + private1_url = reverse('severities-detail', kwargs={"pk": data.private_severity1.pk}) + private2_url = reverse('severities-detail', kwargs={"pk": data.private_severity2.pk}) + blocked_url = reverse('severities-detail', kwargs={"pk": data.blocked_severity.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + severity_data = serializers.SeveritySerializer(data.public_severity).data + severity_data["name"] = "test" + severity_data = json.dumps(severity_data) + results = helper_test_http_method(client, 'put', public_url, severity_data, users) + assert results == [401, 403, 403, 403, 200] + + severity_data = serializers.SeveritySerializer(data.private_severity1).data + severity_data["name"] = "test" + severity_data = json.dumps(severity_data) + results = helper_test_http_method(client, 'put', private1_url, severity_data, users) + assert results == [401, 403, 403, 403, 200] + + severity_data = serializers.SeveritySerializer(data.private_severity2).data + severity_data["name"] = "test" + severity_data = json.dumps(severity_data) + results = helper_test_http_method(client, 'put', private2_url, severity_data, users) + assert results == [401, 403, 403, 403, 200] + + severity_data = serializers.SeveritySerializer(data.blocked_severity).data + severity_data["name"] = "test" + severity_data = json.dumps(severity_data) + results = helper_test_http_method(client, 'put', blocked_url, severity_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_severity_delete(client, data): + public_url = reverse('severities-detail', kwargs={"pk": data.public_severity.pk}) + public_url += f'?moveTo={data.public_severity_aux.pk}' + private1_url = reverse('severities-detail', kwargs={"pk": data.private_severity1.pk}) + private1_url += f'?moveTo={data.private_severity1_aux.pk}' + private2_url = reverse('severities-detail', kwargs={"pk": data.private_severity2.pk}) + private2_url += f'?moveTo={data.private_severity2_aux.pk}' + blocked_url = reverse('severities-detail', kwargs={"pk": data.blocked_severity.pk}) + blocked_url += f'?moveTo={data.blocked_severity_aux.pk}' + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_severity_list(client, data): + url = reverse('severities-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 8 + assert response.status_code == 200 + + +def test_severity_patch(client, data): + public_url = reverse('severities-detail', kwargs={"pk": data.public_severity.pk}) + private1_url = reverse('severities-detail', kwargs={"pk": data.private_severity1.pk}) + private2_url = reverse('severities-detail', kwargs={"pk": data.private_severity2.pk}) + blocked_url = reverse('severities-detail', kwargs={"pk": data.blocked_severity.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_severity_action_bulk_update_order(client, data): + url = reverse('severities-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_severities": [(1, 2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_severities": [(1, 2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_severities": [(1, 2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_severities": [(1, 2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +##################################################### +# Memberships +##################################################### + +def test_membership_retrieve(client, data): + public_url = reverse('memberships-detail', kwargs={"pk": data.public_membership.pk}) + private1_url = reverse('memberships-detail', kwargs={"pk": data.private_membership1.pk}) + private2_url = reverse('memberships-detail', kwargs={"pk": data.private_membership2.pk}) + blocked_url = reverse('memberships-detail', kwargs={"pk": data.blocked_membership.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_membership_update(client, data): + public_url = reverse('memberships-detail', kwargs={"pk": data.public_membership.pk}) + private1_url = reverse('memberships-detail', kwargs={"pk": data.private_membership1.pk}) + private2_url = reverse('memberships-detail', kwargs={"pk": data.private_membership2.pk}) + blocked_url = reverse('memberships-detail', kwargs={"pk": data.blocked_membership.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + membership_data = serializers.MembershipSerializer(data.public_membership).data + membership_data["token"] = "test" + membership_data["username"] = data.public_membership.user.email + membership_data = json.dumps(membership_data) + results = helper_test_http_method(client, 'put', public_url, membership_data, users) + assert results == [401, 403, 403, 403, 200] + + membership_data = serializers.MembershipSerializer(data.private_membership1).data + membership_data["token"] = "test" + membership_data["username"] = data.private_membership1.user.email + membership_data = json.dumps(membership_data) + results = helper_test_http_method(client, 'put', private1_url, membership_data, users) + assert results == [401, 403, 403, 403, 200] + + membership_data = serializers.MembershipSerializer(data.private_membership2).data + membership_data["token"] = "test" + membership_data["username"] = data.private_membership2.user.email + membership_data = json.dumps(membership_data) + results = helper_test_http_method(client, 'put', private2_url, membership_data, users) + assert results == [401, 403, 403, 403, 200] + + membership_data = serializers.MembershipSerializer(data.blocked_membership).data + membership_data["token"] = "test" + membership_data["username"] = data.blocked_membership.user.email + membership_data = json.dumps(membership_data) + results = helper_test_http_method(client, 'put', blocked_url, membership_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_membership_delete(client, data): + public_url = reverse('memberships-detail', kwargs={"pk": data.public_membership.pk}) + private1_url = reverse('memberships-detail', kwargs={"pk": data.private_membership1.pk}) + private2_url = reverse('memberships-detail', kwargs={"pk": data.private_membership2.pk}) + blocked_url = reverse('memberships-detail', kwargs={"pk": data.blocked_membership.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_membership_list(client, data): + url = reverse('memberships-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 5 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 5 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 5 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 11 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 11 + assert response.status_code == 200 + + +def test_membership_patch(client, data): + public_url = reverse('memberships-detail', kwargs={"pk": data.public_membership.pk}) + private1_url = reverse('memberships-detail', kwargs={"pk": data.private_membership1.pk}) + private2_url = reverse('memberships-detail', kwargs={"pk": data.private_membership2.pk}) + blocked_url = reverse('memberships-detail', kwargs={"pk": data.blocked_membership.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_membership_create(client, data): + url = reverse('memberships-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + membership_data = serializers.MembershipSerializer(data.public_membership).data + del(membership_data["id"]) + del(membership_data["user"]) + membership_data["username"] = "test1@test.com" + membership_data = json.dumps(membership_data) + results = helper_test_http_method(client, 'post', url, membership_data, users) + assert results == [401, 403, 403, 403, 201] + + membership_data = serializers.MembershipSerializer(data.private_membership1).data + del(membership_data["id"]) + del(membership_data["user"]) + membership_data["username"] = "test2@test.com" + membership_data = json.dumps(membership_data) + results = helper_test_http_method(client, 'post', url, membership_data, users) + assert results == [401, 403, 403, 403, 201] + + membership_data = serializers.MembershipSerializer(data.private_membership2).data + del(membership_data["id"]) + del(membership_data["user"]) + membership_data["username"] = "test3@test.com" + membership_data = json.dumps(membership_data) + results = helper_test_http_method(client, 'post', url, membership_data, users) + assert results == [401, 403, 403, 403, 201] + + membership_data = serializers.MembershipSerializer(data.blocked_membership).data + del(membership_data["id"]) + del(membership_data["user"]) + membership_data["username"] = "test4@test.com" + membership_data = json.dumps(membership_data) + results = helper_test_http_method(client, 'post', url, membership_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_membership_action_bulk_create(client, data): + url = reverse('memberships-bulk-create') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + bulk_data = { + "project_id": data.public_project.id, + "bulk_memberships": [ + {"role_id": data.public_membership.role.pk, "username": "test1@test.com"}, + {"role_id": data.public_membership.role.pk, "username": "test2@test.com"}, + ] + } + bulk_data = json.dumps(bulk_data) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 403, 200] + + bulk_data = { + "project_id": data.private_project1.id, + "bulk_memberships": [ + {"role_id": data.private_membership1.role.pk, "username": "test1@test.com"}, + {"role_id": data.private_membership1.role.pk, "username": "test2@test.com"}, + ] + } + bulk_data = json.dumps(bulk_data) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 403, 200] + + bulk_data = { + "project_id": data.private_project2.id, + "bulk_memberships": [ + {"role_id": data.private_membership2.role.pk, "username": "test1@test.com"}, + {"role_id": data.private_membership2.role.pk, "username": "test2@test.com"}, + ] + } + bulk_data = json.dumps(bulk_data) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 403, 200] + + bulk_data = { + "project_id": data.blocked_project.id, + "bulk_memberships": [ + {"role_id": data.blocked_membership.role.pk, "username": "test1@test.com"}, + {"role_id": data.blocked_membership.role.pk, "username": "test2@test.com"}, + ] + } + bulk_data = json.dumps(bulk_data) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_membership_action_resend_invitation(client, data): + public_invitation = f.InvitationFactory(project=data.public_project, role__project=data.public_project) + private_invitation1 = f.InvitationFactory(project=data.private_project1, role__project=data.private_project1) + private_invitation2 = f.InvitationFactory(project=data.private_project2, role__project=data.private_project2) + blocked_invitation = f.InvitationFactory(project=data.blocked_project, role__project=data.blocked_project) + + public_url = reverse('memberships-resend-invitation', kwargs={"pk": public_invitation.pk}) + private1_url = reverse('memberships-resend-invitation', kwargs={"pk": private_invitation1.pk}) + private2_url = reverse('memberships-resend-invitation', kwargs={"pk": private_invitation2.pk}) + blocked_url = reverse('memberships-resend-invitation', kwargs={"pk": blocked_invitation.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 404, 403, 204] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 404, 403, 451] + + +##################################################### +# Project Templates +##################################################### + +def test_project_template_retrieve(client, data): + url = reverse('project-templates-detail', kwargs={"pk": data.project_template.pk}) + + users = [ + None, + data.registered_user, + data.superuser, + ] + + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [200, 200, 200] + + +def test_project_template_update(client, data): + url = reverse('project-templates-detail', kwargs={"pk": data.project_template.pk}) + + users = [ + None, + data.registered_user, + data.superuser, + ] + + project_template_data = serializers.ProjectTemplateSerializer(data.project_template).data + project_template_data["default_owner_role"] = "test" + project_template_data = json.dumps(project_template_data) + results = helper_test_http_method(client, 'put', url, project_template_data, users) + assert results == [401, 403, 200] + + +def test_project_template_delete(client, data): + url = reverse('project-templates-detail', kwargs={"pk": data.project_template.pk}) + + users = [ + None, + data.registered_user, + data.superuser, + ] + + results = helper_test_http_method(client, 'delete', url, None, users) + assert results == [401, 403, 204] + + +def test_project_template_list(client, data): + url = reverse('project-templates-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 1 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 1 + assert response.status_code == 200 + + client.login(data.superuser) + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 1 + assert response.status_code == 200 + + +def test_project_template_patch(client, data): + url = reverse('project-templates-detail', kwargs={"pk": data.project_template.pk}) + + users = [ + None, + data.registered_user, + data.superuser, + ] + + results = helper_test_http_method(client, 'patch', url, '{"name": "Test"}', users) + assert results == [401, 403, 200] + + +##################################################### +# Tags +##################################################### + +def test_create_tag(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "tag": "testtest", + "color": "#123123" + }) + + url = reverse('projects-create-tag', kwargs={"pk": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-create-tag', kwargs={"pk": data.private_project1.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-create-tag', kwargs={"pk": data.private_project2.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 404, 404, 403, 200] + + url = reverse('projects-create-tag', kwargs={"pk": data.blocked_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 404, 404, 403, 451] + + +def test_edit_tag(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "from_tag": "tag1", + "to_tag": "renamedtag1", + "color": "#123123" + }) + + url = reverse('projects-edit-tag', kwargs={"pk": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-edit-tag', kwargs={"pk": data.private_project1.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-edit-tag', kwargs={"pk": data.private_project2.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 404, 404, 403, 200] + + url = reverse('projects-edit-tag', kwargs={"pk": data.blocked_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 404, 404, 403, 451] + + +def test_delete_tag(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "tag": "tag2", + }) + + url = reverse('projects-delete-tag', kwargs={"pk": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-delete-tag', kwargs={"pk": data.private_project1.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-delete-tag', kwargs={"pk": data.private_project2.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 404, 404, 403, 200] + + url = reverse('projects-delete-tag', kwargs={"pk": data.blocked_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 404, 404, 403, 451] + + +def test_mix_tags(client, data): + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "from_tags": ["tag1"], + "to_tag": "tag3" + }) + + url = reverse('projects-mix-tags', kwargs={"pk": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-mix-tags', kwargs={"pk": data.private_project1.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 200] + + url = reverse('projects-mix-tags', kwargs={"pk": data.private_project2.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 404, 404, 403, 200] + + url = reverse('projects-mix-tags', kwargs={"pk": data.blocked_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 404, 404, 403, 451] diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py new file mode 100644 index 000000000..507a4bde3 --- /dev/null +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -0,0 +1,788 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse +from django.apps import apps + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects import models as project_models +from taiga.projects.serializers import ProjectSerializer +from taiga.permissions.choices import MEMBERS_PERMISSIONS +from taiga.projects.utils import attach_extra_info + +from tests import factories as f +from tests.utils import helper_test_http_method, helper_test_http_method_and_count + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=['view_project'], + public_permissions=['view_project']) + m.public_project = attach_extra_info(project_models.Project.objects.all()).get(id=m.public_project.id) + + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=['view_project'], + public_permissions=['view_project'], + owner=m.project_owner) + m.private_project1 = attach_extra_info(project_models.Project.objects.all()).get(id=m.private_project1.id) + + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.private_project2 = attach_extra_info(project_models.Project.objects.all()).get(id=m.private_project2.id) + + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_extra_info(project_models.Project.objects.all()).get(id=m.blocked_project.id) + + f.RoleFactory(project=m.public_project) + + m.membership = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.membership = f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.membership = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.membership = f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + role__project=m.public_project, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + role__project=m.private_project1, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + role__project=m.private_project2, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + role__project=m.blocked_project, + is_admin=True) + + ContentType = apps.get_model("contenttypes", "ContentType") + Project = apps.get_model("projects", "Project") + + project_ct = ContentType.objects.get_for_model(Project) + + f.LikeFactory(content_type=project_ct, object_id=m.public_project.pk, user=m.project_member_with_perms) + f.LikeFactory(content_type=project_ct, object_id=m.public_project.pk, user=m.project_owner) + f.LikeFactory(content_type=project_ct, object_id=m.private_project1.pk, user=m.project_member_with_perms) + f.LikeFactory(content_type=project_ct, object_id=m.private_project1.pk, user=m.project_owner) + f.LikeFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_member_with_perms) + f.LikeFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_owner) + f.LikeFactory(content_type=project_ct, object_id=m.blocked_project.pk, user=m.project_member_with_perms) + f.LikeFactory(content_type=project_ct, object_id=m.blocked_project.pk, user=m.project_owner) + + return m + + +def test_project_retrieve(client, data): + public_url = reverse('projects-detail', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-detail', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-detail', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-detail', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 200, 200] + + +def test_project_update(client, data): + url = reverse('projects-detail', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-detail', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + + project_data = ProjectSerializer(data.private_project2).data + # Because in serializer is a dict anin model is a list of lists + project_data["tags_colors"] = list(map(list, project_data["tags_colors"].items())) + project_data["is_private"] = False + + results = helper_test_http_method(client, 'put', url, json.dumps(project_data), users) + assert results == [401, 403, 403, 200] + + project_data = ProjectSerializer(data.blocked_project).data + # Because in serializer is a dict anin model is a list of lists + project_data["tags_colors"] = list(map(list, project_data["tags_colors"].items())) + project_data["is_private"] = False + + results = helper_test_http_method(client, 'put', blocked_url, json.dumps(project_data), users) + assert results == [401, 403, 403, 451] + + +def test_project_delete(client, data): + url = reverse('projects-detail', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-detail', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'delete', url, None, users) + assert results == [401, 403, 403, 204] + + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_project_list(client, data): + url = reverse('projects-list') + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 4 + assert response.status_code == 200 + + +def test_project_patch(client, data): + url = reverse('projects-detail', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-detail', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + data = json.dumps({"is_private": False}) + + results = helper_test_http_method(client, 'patch', url, data, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'patch', blocked_url, data, users) + assert results == [401, 403, 403, 451] + + +def test_project_action_stats(client, data): + public_url = reverse('projects-stats', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-stats', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-stats', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-stats', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 404, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 404, 200, 200] + + +def test_project_action_issues_stats(client, data): + public_url = reverse('projects-issues-stats', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-issues-stats', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-issues-stats', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-issues-stats', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 404, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 404, 200, 200] + + +def test_project_action_like(client, data): + public_url = reverse('projects-like', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-like', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-like', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-like', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 451, 451] + + +def test_project_action_unlike(client, data): + public_url = reverse('projects-unlike', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-unlike', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-unlike', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-unlike', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 451, 451] + + +def test_project_fans_list(client, data): + public_url = reverse('project-fans-list', kwargs={"resource_id": data.public_project.pk}) + private1_url = reverse('project-fans-list', kwargs={"resource_id": data.private_project1.pk}) + private2_url = reverse('project-fans-list', kwargs={"resource_id": data.private_project2.pk}) + blocked_url = reverse('project-fans-list', kwargs={"resource_id": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_count(client, 'get', public_url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 2), (200, 2)] + results = helper_test_http_method_and_count(client, 'get', private1_url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 2), (200, 2)] + results = helper_test_http_method_and_count(client, 'get', private2_url, None, users) + assert results == [(401, 0), (403, 0), (403, 0), (200, 2), (200, 2)] + results = helper_test_http_method_and_count(client, 'get', blocked_url, None, users) + assert results == [(401, 0), (403, 0), (403, 0), (200, 2), (200, 2)] + + +def test_project_fans_retrieve(client, data): + public_url = reverse('project-fans-detail', kwargs={"resource_id": data.public_project.pk, + "pk": data.project_owner.pk}) + private1_url = reverse('project-fans-detail', kwargs={"resource_id": data.private_project1.pk, + "pk": data.project_owner.pk}) + private2_url = reverse('project-fans-detail', kwargs={"resource_id": data.private_project2.pk, + "pk": data.project_owner.pk}) + blocked_url = reverse('project-fans-detail', kwargs={"resource_id": data.blocked_project.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_project_watchers_list(client, data): + public_url = reverse('project-watchers-list', kwargs={"resource_id": data.public_project.pk}) + private1_url = reverse('project-watchers-list', kwargs={"resource_id": data.private_project1.pk}) + private2_url = reverse('project-watchers-list', kwargs={"resource_id": data.private_project2.pk}) + blocked_url = reverse('project-watchers-list', kwargs={"resource_id": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_count(client, 'get', public_url, None, users) + assert results == [(200, 1), (200, 1), (200, 1), (200, 1), (200, 1)] + results = helper_test_http_method_and_count(client, 'get', private1_url, None, users) + assert results == [(200, 3), (200, 3), (200, 3), (200, 3), (200, 3)] + results = helper_test_http_method_and_count(client, 'get', private2_url, None, users) + assert results == [(401, 0), (403, 0), (403, 0), (200, 3), (200, 3)] + results = helper_test_http_method_and_count(client, 'get', blocked_url, None, users) + assert results == [(401, 0), (403, 0), (403, 0), (200, 3), (200, 3)] + + +def test_project_watchers_retrieve(client, data): + public_url = reverse('project-watchers-detail', kwargs={"resource_id": data.public_project.pk, + "pk": data.project_owner.pk}) + private1_url = reverse('project-watchers-detail', kwargs={"resource_id": data.private_project1.pk, + "pk": data.project_owner.pk}) + private2_url = reverse('project-watchers-detail', kwargs={"resource_id": data.private_project2.pk, + "pk": data.project_owner.pk}) + blocked_url = reverse('project-watchers-detail', kwargs={"resource_id": data.blocked_project.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_project_action_create_template(client, data): + public_url = reverse('projects-create-template', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-create-template', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-create-template', kwargs={"pk": data.private_project2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner, + data.superuser, + ] + + template_data = json.dumps({ + "template_name": "test", + "template_description": "test", + }) + + results = helper_test_http_method(client, 'post', public_url, template_data, users) + assert results == [401, 403, 403, 403, 403, 201] + results = helper_test_http_method(client, 'post', private1_url, template_data, users) + assert results == [401, 403, 403, 403, 403, 201] + results = helper_test_http_method(client, 'post', private2_url, template_data, users) + assert results == [401, 404, 404, 403, 403, 201] + + +def test_invitations_list(client, data): + url = reverse('invitations-list') + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [403, 403, 403, 403] + + +def test_invitations_retrieve(client, data): + invitation = f.MembershipFactory(user=None) + + url = reverse('invitations-detail', kwargs={'token': invitation.token}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [200, 200, 200, 200] + + +def test_regenerate_epics_csv_uuid(client, data): + public_url = reverse('projects-regenerate-epics-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-regenerate-epics-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-regenerate-epics-csv-uuid', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-regenerate-epics-csv-uuid', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 403, 200] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 403, 451] + + +def test_delete_epics_csv_uuid(client, data): + public_url = reverse('projects-delete-epics-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-delete-epics-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-delete-epics-csv-uuid', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-delete-epics-csv-uuid', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 403, 200] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 403, 451] + + +def test_regenerate_userstories_csv_uuid(client, data): + public_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 403, 200] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 403, 451] + + +def test_delete_userstories_csv_uuid(client, data): + public_url = reverse('projects-delete-userstories-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-delete-userstories-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-delete-userstories-csv-uuid', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-delete-userstories-csv-uuid', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 403, 200] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 403, 451] + + +def test_regenerate_tasks_csv_uuid(client, data): + public_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 403, 200] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 403, 451] + + +def test_delete_tasks_csv_uuid(client, data): + public_url = reverse('projects-delete-tasks-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-delete-tasks-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-delete-tasks-csv-uuid', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-delete-tasks-csv-uuid', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 403, 200] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 403, 451] + + +def test_regenerate_issues_csv_uuid(client, data): + public_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 403, 200] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 403, 451] + + +def test_delete_issues_csv_uuid(client, data): + public_url = reverse('projects-delete-issues-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-delete-issues-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-delete-issues-csv-uuid', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-delete-issues-csv-uuid', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 403, 200] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 403, 451] + + +def test_project_action_watch(client, data): + public_url = reverse('projects-watch', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-watch', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-watch', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-watch', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 451, 451] + + +def test_project_action_unwatch(client, data): + public_url = reverse('projects-unwatch', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-unwatch', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-unwatch', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-unwatch', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [401, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 451, 451] + + +def test_project_list_with_discover_mode_enabled(client, data): + url = "{}?{}".format(reverse('projects-list'), "discover_mode=true") + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + projects_data = json.loads(response.content.decode('utf-8')) + assert len(projects_data) == 2 + assert response.status_code == 200 + + +def test_project_duplicate(client, data): + public_url = reverse('projects-duplicate', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-duplicate', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-duplicate', kwargs={"pk": data.private_project2.pk}) + blocked_url = reverse('projects-duplicate', kwargs={"pk": data.blocked_project.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + + data = json.dumps({ + "name": "test", + "description": "description", + "is_private": True, + "users": [] + }) + + results = helper_test_http_method(client, 'post', public_url, data, users) + assert results == [401, 201, 201, 201] + results = helper_test_http_method(client, 'post', private1_url, data, users) + assert results == [401, 201, 201, 201] + results = helper_test_http_method(client, 'post', private2_url, data, users) + assert results == [401, 404, 201, 201] + results = helper_test_http_method(client, 'post', blocked_url, data, users) + assert results == [401, 404, 451, 451] diff --git a/tests/integration/resources_permissions/test_resolver_resources.py b/tests/integration/resources_permissions/test_resolver_resources.py new file mode 100644 index 000000000..27199ecb7 --- /dev/null +++ b/tests/integration/resources_permissions/test_resolver_resources.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + slug="public") + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + slug="private1") + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + slug="private2") + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + m.view_only_membership = f.MembershipFactory(project=m.private_project2, + user=m.other_user, + role__project=m.private_project2, + role__permissions=["view_project"]) + + m.epic = f.EpicFactory(project=m.private_project2, ref=4) + m.us = f.UserStoryFactory(project=m.private_project2, ref=1) + m.task = f.TaskFactory(project=m.private_project2, ref=2) + m.issue = f.IssueFactory(project=m.private_project2, ref=3) + m.milestone = f.MilestoneFactory(project=m.private_project2, slug="milestone-test-1") + + return m + + +def test_resolver_list(client, data): + url = reverse('resolver-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', "{}?project={}".format(url, data.public_project.slug), None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', "{}?project={}".format(url, data.private_project1.slug), None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', "{}?project={}".format(url, data.private_project2.slug), None, users) + assert results == [401, 403, 403, 200, 200] + + client.login(data.other_user) + response = client.json.get("{}?project={}&epic={}&us={}&task={}&issue={}&milestone={}".format(url, + data.private_project2.slug, + data.epic.ref, + data.us.ref, + data.task.ref, + data.issue.ref, + data.milestone.slug)) + assert response.data == {"project": data.private_project2.pk} + + client.login(data.project_owner) + response = client.json.get("{}?project={}&epic={}&us={}&task={}&issue={}&milestone={}".format(url, + data.private_project2.slug, + data.epic.ref, + data.us.ref, + data.task.ref, + data.issue.ref, + data.milestone.slug)) + assert response.data == {"project": data.private_project2.pk, + "epic": data.epic.pk, + "us": data.us.pk, + "task": data.task.pk, + "issue": data.issue.pk, + "milestone": data.milestone.pk} + + response = client.json.get("{}?project={}&ref={}".format(url, + data.private_project2.slug, + data.epic.ref)) + assert response.data == {"project": data.private_project2.pk, + "epic": data.epic.pk} + + response = client.json.get("{}?project={}&ref={}".format(url, + data.private_project2.slug, + data.us.ref)) + assert response.data == {"project": data.private_project2.pk, + "us": data.us.pk} + + response = client.json.get("{}?project={}&ref={}".format(url, + data.private_project2.slug, + data.task.ref)) + assert response.data == {"project": data.private_project2.pk, + "task": data.task.pk} + + response = client.json.get("{}?project={}&ref={}".format(url, + data.private_project2.slug, + data.issue.ref)) + assert response.data == {"project": data.private_project2.pk, + "issue": data.issue.pk} diff --git a/tests/integration/resources_permissions/test_search_resources.py b/tests/integration/resources_permissions/test_search_resources.py new file mode 100644 index 000000000..78a0d5049 --- /dev/null +++ b/tests/integration/resources_permissions/test_search_resources.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS + +from tests import factories as f +from tests.utils import helper_test_http_method_and_keys, disconnect_signals, reconnect_signals + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + m.public_issue = f.IssueFactory(project=m.public_project, + status__project=m.public_project, + severity__project=m.public_project, + priority__project=m.public_project, + type__project=m.public_project, + milestone__project=m.public_project) + m.private_issue1 = f.IssueFactory(project=m.private_project1, + status__project=m.private_project1, + severity__project=m.private_project1, + priority__project=m.private_project1, + type__project=m.private_project1, + milestone__project=m.private_project1) + m.private_issue2 = f.IssueFactory(project=m.private_project2, + status__project=m.private_project2, + severity__project=m.private_project2, + priority__project=m.private_project2, + type__project=m.private_project2, + milestone__project=m.private_project2) + + return m + + +def test_search_list(client, data): + url = reverse('search-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_keys(client, 'get', url, {'project': data.public_project.pk}, users) + all_keys = set(['count', 'userstories', 'issues', 'tasks', 'wikipages', 'epics']) + assert results == [(200, all_keys), (200, all_keys), (200, all_keys), (200, all_keys), (200, all_keys)] + results = helper_test_http_method_and_keys(client, 'get', url, {'project': data.private_project1.pk}, users) + assert results == [(200, all_keys), (200, all_keys), (200, all_keys), (200, all_keys), (200, all_keys)] + results = helper_test_http_method_and_keys(client, 'get', url, {'project': data.private_project2.pk}, users) + assert results == [(200, set(['count'])), (200, set(['count'])), (200, set(['count'])), (200, all_keys), (200, all_keys)] diff --git a/tests/integration/resources_permissions/test_storage_resources.py b/tests/integration/resources_permissions/test_storage_resources.py new file mode 100644 index 000000000..c85484353 --- /dev/null +++ b/tests/integration/resources_permissions/test_storage_resources.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.userstorage.serializers import StorageEntrySerializer +from taiga.userstorage.models import StorageEntry + +from tests import factories as f +from tests.utils import helper_test_http_method +from tests.utils import helper_test_http_method_and_count +from tests.utils import disconnect_signals, reconnect_signals + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.user1 = f.UserFactory.create() + m.user2 = f.UserFactory.create() + + m.storage_user1 = f.StorageEntryFactory(owner=m.user1) + m.storage_user2 = f.StorageEntryFactory(owner=m.user2) + m.storage2_user2 = f.StorageEntryFactory(owner=m.user2) + + return m + + +def test_storage_retrieve(client, data): + url = reverse('user-storage-detail', kwargs={"key": data.storage_user1.key}) + + users = [ + None, + data.user1, + data.user2, + ] + + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [404, 200, 404] + + +def test_storage_update(client, data): + url = reverse('user-storage-detail', kwargs={"key": data.storage_user1.key}) + + users = [ + None, + data.user1, + data.user2, + ] + + storage_data = StorageEntrySerializer(data.storage_user1).data + storage_data["key"] = "test" + storage_data = json.dumps(storage_data) + results = helper_test_http_method(client, 'put', url, storage_data, users) + assert results == [401, 200, 404] + + +def test_storage_delete(client, data): + url = reverse('user-storage-detail', kwargs={"key": data.storage_user1.key}) + + users = [ + None, + data.user1, + data.user2, + ] + + results = helper_test_http_method(client, 'delete', url, None, users) + assert results == [401, 204, 404] + + +def test_storage_list(client, data): + url = reverse('user-storage-list') + + users = [ + None, + data.user1, + data.user2, + ] + + results = helper_test_http_method_and_count(client, 'get', url, None, users) + assert results == [(200, 0), (200, 1), (200, 2)] + + +def test_storage_create(client, data): + url = reverse('user-storage-list') + + users = [ + None, + data.user1, + data.user2, + ] + + create_data = json.dumps({ + "key": "test", + "value": {"test": "test-value"}, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: StorageEntry.objects.all().delete()) + assert results == [401, 201, 201] + + +def test_storage_patch(client, data): + url = reverse('user-storage-detail', kwargs={"key": data.storage_user1.key}) + + users = [ + None, + data.user1, + data.user2, + ] + + patch_data = json.dumps({"value": {"test": "test-value"}}) + results = helper_test_http_method(client, 'patch', url, patch_data, users) + assert results == [401, 200, 404] diff --git a/tests/integration/resources_permissions/test_tasks_custom_attributes_resource.py b/tests/integration/resources_permissions/test_tasks_custom_attributes_resource.py new file mode 100644 index 000000000..2e8d4a503 --- /dev/null +++ b/tests/integration/resources_permissions/test_tasks_custom_attributes_resource.py @@ -0,0 +1,454 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.custom_attributes import serializers +from taiga.permissions.choices import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS) + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project2, + role__permissions=[]) + + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_task_ca = f.TaskCustomAttributeFactory(project=m.public_project) + m.private_task_ca1 = f.TaskCustomAttributeFactory(project=m.private_project1) + m.private_task_ca2 = f.TaskCustomAttributeFactory(project=m.private_project2) + m.blocked_task_ca = f.TaskCustomAttributeFactory(project=m.blocked_project) + + m.public_task = f.TaskFactory(project=m.public_project, + status__project=m.public_project, + milestone__project=m.public_project, + user_story__project=m.public_project) + m.private_task1 = f.TaskFactory(project=m.private_project1, + status__project=m.private_project1, + milestone__project=m.private_project1, + user_story__project=m.private_project1) + m.private_task2 = f.TaskFactory(project=m.private_project2, + status__project=m.private_project2, + milestone__project=m.private_project2, + user_story__project=m.private_project2) + m.blocked_task = f.TaskFactory(project=m.blocked_project, + status__project=m.blocked_project, + milestone__project=m.blocked_project, + user_story__project=m.blocked_project) + + m.public_task_cav = m.public_task.custom_attributes_values + m.private_task_cav1 = m.private_task1.custom_attributes_values + m.private_task_cav2 = m.private_task2.custom_attributes_values + m.blocked_task_cav = m.blocked_task.custom_attributes_values + + return m + + +######################################################### +# Task Custom Attribute +######################################################### + +def test_task_custom_attribute_retrieve(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.pk}) + blocked_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.blocked_task_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_custom_attribute_create(client, data): + public_url = reverse('task-custom-attributes-list') + private1_url = reverse('task-custom-attributes-list') + private2_url = reverse('task-custom-attributes-list') + blocked_url = reverse('task-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_ca_data = {"name": "test-new", "project": data.public_project.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', public_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + task_ca_data = {"name": "test-new", "project": data.private_project1.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', private1_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + task_ca_data = {"name": "test-new", "project": data.private_project2.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', private2_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + task_ca_data = {"name": "test-new", "project": data.blocked_project.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', blocked_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_task_custom_attribute_update(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.pk}) + blocked_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.blocked_task_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.public_task_ca).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', public_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.private_task_ca1).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', private1_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.private_task_ca2).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', private2_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.blocked_task_ca).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', private2_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_task_custom_attribute_delete(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.pk}) + blocked_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.blocked_task_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + + +def test_task_custom_attribute_list(client, data): + url = reverse('task-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + +def test_task_custom_attribute_patch(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.pk}) + blocked_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.blocked_task_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_task_custom_attribute_action_bulk_update_order(client, data): + url = reverse('task-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + +######################################################### +# Task Custom Attribute +######################################################### + + +def test_task_custom_attributes_values_retrieve(client, data): + public_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.public_task.pk}) + private_url1 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task1.pk}) + private_url2 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task2.pk}) + blocked_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_custom_attributes_values_update(client, data): + public_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.public_task.pk}) + private_url1 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task1.pk}) + private_url2 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task2.pk}) + blocked_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_data = serializers.TaskCustomAttributesValuesSerializer(data.public_task_cav).data + task_data["attributes_values"] = {str(data.public_task_ca.pk): "test"} + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', public_url, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = serializers.TaskCustomAttributesValuesSerializer(data.private_task_cav1).data + task_data["attributes_values"] = {str(data.private_task_ca1.pk): "test"} + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url1, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = serializers.TaskCustomAttributesValuesSerializer(data.private_task_cav2).data + task_data["attributes_values"] = {str(data.private_task_ca2.pk): "test"} + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url2, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = serializers.TaskCustomAttributesValuesSerializer(data.blocked_task_cav).data + task_data["attributes_values"] = {str(data.blocked_task_ca.pk): "test"} + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', blocked_url, task_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_custom_attributes_values_patch(client, data): + public_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.public_task.pk}) + private_url1 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task1.pk}) + private_url2 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task2.pk}) + blocked_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_task_ca.pk): "test"}, + "version": data.public_task.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_task_ca1.pk): "test"}, + "version": data.private_task1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_task_ca2.pk): "test"}, + "version": data.private_task2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.blocked_task_ca.pk): "test"}, + "version": data.blocked_task.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] diff --git a/tests/integration/resources_permissions/test_tasks_resources.py b/tests/integration/resources_permissions/test_tasks_resources.py new file mode 100644 index 000000000..68c5ab5b7 --- /dev/null +++ b/tests/integration/resources_permissions/test_tasks_resources.py @@ -0,0 +1,914 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.tasks.serializers import TaskSerializer +from taiga.projects.tasks.models import Task +from taiga.projects.tasks.utils import attach_extra_info as attach_task_extra_info +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.occ import OCCResourceMixin + +from tests import factories as f +from tests.utils import helper_test_http_method, reconnect_signals +from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_function(function): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)) + ["comment_task"], + owner=m.project_owner, + tasks_csv_uuid=uuid.uuid4().hex) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + tasks_csv_uuid=uuid.uuid4().hex) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + tasks_csv_uuid=uuid.uuid4().hex) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + tasks_csv_uuid=uuid.uuid4().hex, + blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) + + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + milestone_public_task = f.MilestoneFactory(project=m.public_project) + milestone_private_task1 = f.MilestoneFactory(project=m.private_project1) + milestone_private_task2 = f.MilestoneFactory(project=m.private_project2) + milestone_blocked_task = f.MilestoneFactory(project=m.blocked_project) + + m.public_task = f.TaskFactory(project=m.public_project, + status__project=m.public_project, + milestone=milestone_public_task, + user_story__project=m.public_project, + user_story__milestone=milestone_public_task) + m.public_task = attach_task_extra_info(Task.objects.all()).get(id=m.public_task.id) + + m.private_task1 = f.TaskFactory(project=m.private_project1, + status__project=m.private_project1, + milestone=milestone_private_task1, + user_story__project=m.private_project1, + user_story__milestone=milestone_private_task1) + m.private_task1 = attach_task_extra_info(Task.objects.all()).get(id=m.private_task1.id) + + m.private_task2 = f.TaskFactory(project=m.private_project2, + status__project=m.private_project2, + milestone=milestone_private_task2, + user_story__project=m.private_project2, + user_story__milestone=milestone_private_task2) + m.private_task2 = attach_task_extra_info(Task.objects.all()).get(id=m.private_task2.id) + + m.blocked_task = f.TaskFactory(project=m.blocked_project, + status__project=m.blocked_project, + milestone=milestone_blocked_task, + user_story__project=m.blocked_project, + user_story__milestone=milestone_blocked_task) + m.blocked_task = attach_task_extra_info(Task.objects.all()).get(id=m.blocked_task.id) + + m.public_project.default_task_status = m.public_task.status + m.public_project.save() + m.private_project1.default_task_status = m.private_task1.status + m.private_project1.save() + m.private_project2.default_task_status = m.private_task2.status + m.private_project2.save() + m.blocked_project.default_task_status = m.blocked_task.status + m.blocked_project.save() + + return m + + +def test_task_list(client, data): + url = reverse('tasks-list') + + response = client.get(url) + tasks_data = json.loads(response.content.decode('utf-8')) + assert len(tasks_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + tasks_data = json.loads(response.content.decode('utf-8')) + assert len(tasks_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + tasks_data = json.loads(response.content.decode('utf-8')) + assert len(tasks_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + tasks_data = json.loads(response.content.decode('utf-8')) + assert len(tasks_data) == 4 + assert response.status_code == 200 + + +def test_task_retrieve(client, data): + public_url = reverse('tasks-detail', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-detail', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-detail', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-detail', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_create(client, data): + url = reverse('tasks-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({ + "subject": "test", + "ref": 1, + "project": data.public_project.pk, + "status": data.public_project.task_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 2, + "project": data.private_project1.pk, + "status": data.private_project1.task_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 3, + "project": data.private_project2.pk, + "status": data.private_project2.task_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "subject": "test", + "ref": 3, + "project": data.blocked_project.pk, + "status": data.blocked_project.task_statuses.all()[0].pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_put_update(client, data): + public_url = reverse('tasks-detail', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-detail', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-detail', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-detail', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + task_data = TaskSerializer(data.public_task).data + task_data["subject"] = "test" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', public_url, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = TaskSerializer(data.private_task1).data + task_data["subject"] = "test" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url1, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = TaskSerializer(data.private_task2).data + task_data["subject"] = "test" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url2, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = TaskSerializer(data.blocked_task).data + task_data["subject"] = "test" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', blocked_url, task_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_put_comment(client, data): + public_url = reverse('tasks-detail', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-detail', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-detail', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-detail', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + task_data = TaskSerializer(data.public_task).data + task_data["comment"] = "test comment" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', public_url, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = TaskSerializer(data.private_task1).data + task_data["comment"] = "test comment" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url1, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = TaskSerializer(data.private_task2).data + task_data["comment"] = "test comment" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url2, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = TaskSerializer(data.blocked_task).data + task_data["comment"] = "test comment" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', blocked_url, task_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_put_update_and_comment(client, data): + public_url = reverse('tasks-detail', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-detail', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-detail', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-detail', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + task_data = TaskSerializer(data.public_task).data + task_data["subject"] = "test" + task_data["comment"] = "test comment" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', public_url, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = TaskSerializer(data.private_task1).data + task_data["subject"] = "test" + task_data["comment"] = "test comment" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url1, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = TaskSerializer(data.private_task2).data + task_data["subject"] = "test" + task_data["comment"] = "test comment" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url2, task_data, users) + assert results == [401, 403, 403, 200, 200] + + task_data = TaskSerializer(data.blocked_task).data + task_data["subject"] = "test" + task_data["comment"] = "test comment" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', blocked_url, task_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_put_update_with_project_change(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + user3 = f.UserFactory.create() + user4 = f.UserFactory.create() + project1 = f.ProjectFactory() + project2 = f.ProjectFactory() + + task_status1 = f.TaskStatusFactory.create(project=project1) + task_status2 = f.TaskStatusFactory.create(project=project2) + + project1.default_task_status = task_status1 + project2.default_task_status = task_status2 + + project1.save() + project2.save() + + project1 = attach_project_extra_info(Project.objects.all()).get(id=project1.id) + project2 = attach_project_extra_info(Project.objects.all()).get(id=project2.id) + + f.MembershipFactory(project=project1, + user=user1, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user1, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project1, + user=user2, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user3, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + task = f.TaskFactory.create(project=project1) + task = attach_task_extra_info(Task.objects.all()).get(id=task.id) + + url = reverse('tasks-detail', kwargs={"pk": task.pk}) + + # Test user with permissions in both projects + client.login(user1) + + task_data = TaskSerializer(task).data + task_data["project"] = project2.id + task_data = json.dumps(task_data) + + response = client.put(url, data=task_data, content_type="application/json") + + assert response.status_code == 200 + + task.project = project1 + task.save() + + # Test user with permissions in only origin project + client.login(user2) + + task_data = TaskSerializer(task).data + task_data["project"] = project2.id + task_data = json.dumps(task_data) + + response = client.put(url, data=task_data, content_type="application/json") + + assert response.status_code == 403 + + task.project = project1 + task.save() + + # Test user with permissions in only destionation project + client.login(user3) + + task_data = TaskSerializer(task).data + task_data["project"] = project2.id + task_data = json.dumps(task_data) + + response = client.put(url, data=task_data, content_type="application/json") + + assert response.status_code == 403 + + task.project = project1 + task.save() + + # Test user without permissions in the projects + client.login(user4) + + task_data = TaskSerializer(task).data + task_data["project"] = project2.id + task_data = json.dumps(task_data) + + response = client.put(url, data=task_data, content_type="application/json") + + assert response.status_code == 403 + + task.project = project1 + task.save() + + +def test_task_patch_update(client, data): + public_url = reverse('tasks-detail', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-detail', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-detail', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-detail', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"subject": "test", "version": data.public_task.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_task1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_task2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.blocked_task.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_patch_comment(client, data): + public_url = reverse('tasks-detail', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-detail', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-detail', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-detail', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"comment": "test comment", "version": data.public_task.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 200, 200, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_task1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_task2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.blocked_task.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_patch_update_and_comment(client, data): + public_url = reverse('tasks-detail', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-detail', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-detail', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-detail', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.public_task.version + }) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_task1.version + }) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_task2.version + }) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.blocked_task.version + }) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_delete(client, data): + public_url = reverse('tasks-detail', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-detail', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-detail', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-detail', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_task_action_bulk_create(client, data): + url = reverse('tasks-bulk-create') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + bulk_data = json.dumps({ + "bulk_tasks": "test1\ntest2", + "us_id": data.public_task.user_story.pk, + "project_id": data.public_task.project.pk, + "milestone_id": data.public_task.milestone.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_tasks": "test1\ntest2", + "us_id": data.private_task1.user_story.pk, + "project_id": data.private_task1.project.pk, + "milestone_id": data.private_task1.milestone.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_tasks": "test1\ntest2", + "us_id": data.private_task2.user_story.pk, + "project_id": data.private_task2.project.pk, + "milestone_id": data.private_task2.milestone.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({ + "bulk_tasks": "test1\ntest2", + "us_id": data.blocked_task.user_story.pk, + "project_id": data.blocked_task.project.pk, + "milestone_id": data.blocked_task.milestone.pk, + }) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_task_action_upvote(client, data): + public_url = reverse('tasks-upvote', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-upvote', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-upvote', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-upvote', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_task_action_downvote(client, data): + public_url = reverse('tasks-downvote', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-downvote', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-downvote', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-downvote', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_task_voters_list(client, data): + public_url = reverse('task-voters-list', kwargs={"resource_id": data.public_task.pk}) + private_url1 = reverse('task-voters-list', kwargs={"resource_id": data.private_task1.pk}) + private_url2 = reverse('task-voters-list', kwargs={"resource_id": data.private_task2.pk}) + blocked_url = reverse('task-voters-list', kwargs={"resource_id": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_voters_retrieve(client, data): + add_vote(data.public_task, data.project_owner) + public_url = reverse('task-voters-detail', kwargs={"resource_id": data.public_task.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_task1, data.project_owner) + private_url1 = reverse('task-voters-detail', kwargs={"resource_id": data.private_task1.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_task2, data.project_owner) + private_url2 = reverse('task-voters-detail', kwargs={"resource_id": data.private_task2.pk, + "pk": data.project_owner.pk}) + + add_vote(data.blocked_task, data.project_owner) + blocked_url = reverse('task-voters-detail', kwargs={"resource_id": data.blocked_task.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_action_watch(client, data): + public_url = reverse('tasks-watch', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-watch', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-watch', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-watch', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_task_action_unwatch(client, data): + public_url = reverse('tasks-unwatch', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-unwatch', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-unwatch', kwargs={"pk": data.private_task2.pk}) + blocked_url = reverse('tasks-unwatch', kwargs={"pk": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_task_watchers_list(client, data): + public_url = reverse('task-watchers-list', kwargs={"resource_id": data.public_task.pk}) + private_url1 = reverse('task-watchers-list', kwargs={"resource_id": data.private_task1.pk}) + private_url2 = reverse('task-watchers-list', kwargs={"resource_id": data.private_task2.pk}) + blocked_url = reverse('task-watchers-list', kwargs={"resource_id": data.blocked_task.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_watchers_retrieve(client, data): + add_watcher(data.public_task, data.project_owner) + public_url = reverse('task-watchers-detail', kwargs={"resource_id": data.public_task.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_task1, data.project_owner) + private_url1 = reverse('task-watchers-detail', kwargs={"resource_id": data.private_task1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_task2, data.project_owner) + private_url2 = reverse('task-watchers-detail', kwargs={"resource_id": data.private_task2.pk, + "pk": data.project_owner.pk}) + + add_watcher(data.blocked_task, data.project_owner) + blocked_url = reverse('task-watchers-detail', kwargs={"resource_id": data.blocked_task.pk, + "pk": data.project_owner.pk}) + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_tasks_csv(client, data): + url = reverse('tasks-csv') + csv_public_uuid = data.public_project.tasks_csv_uuid + csv_private1_uuid = data.private_project1.tasks_csv_uuid + csv_private2_uuid = data.private_project1.tasks_csv_uuid + csv_blocked_uuid = data.blocked_project.tasks_csv_uuid + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_blocked_uuid), None, users) + assert results == [200, 200, 200, 200, 200] diff --git a/tests/integration/resources_permissions/test_timelines_resources.py b/tests/integration/resources_permissions/test_timelines_resources.py new file mode 100644 index 000000000..d9bc326d6 --- /dev/null +++ b/tests/integration/resources_permissions/test_timelines_resources.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + return m + + +def test_user_timeline_retrieve(client, data): + url = reverse('user-timeline-detail', kwargs={"pk": data.registered_user.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [200, 200, 200, 200, 200] + + +def test_project_timeline_retrieve(client, data): + public_url = reverse('project-timeline-detail', kwargs={"pk": data.public_project.pk}) + private_url1 = reverse('project-timeline-detail', kwargs={"pk": data.private_project1.pk}) + private_url2 = reverse('project-timeline-detail', kwargs={"pk": data.private_project2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_users_resources.py b/tests/integration/resources_permissions/test_users_resources.py new file mode 100644 index 000000000..63507ec09 --- /dev/null +++ b/tests/integration/resources_permissions/test_users_resources.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from tempfile import NamedTemporaryFile + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.users.serializers import UserAdminSerializer + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +import pytest +pytestmark = pytest.mark.django_db + +DUMMY_BMP_DATA = b'BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00\x13\x0b\x00\x00\x13\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + return m + + +def test_user_retrieve(client, data): + url = reverse('users-detail', kwargs={"pk": data.registered_user.pk}) + + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [200, 200, 200, 200] + + +def test_user_me(client, data): + url = reverse('users-me') + + users = [ + None, + data.registered_user + ] + + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [401, 200] + + +def test_user_by_username(client, data): + url = reverse('users-by-username') + + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + + results = helper_test_http_method(client, 'get', "{}?username={}".format(url, data.registered_user.username), None, users) + assert results == [200, 200, 200, 200] + + +def test_user_update(client, data): + url = reverse('users-detail', kwargs={"pk": data.registered_user.pk}) + + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + + user_data = UserAdminSerializer(data.registered_user).data + user_data["full_name"] = "test" + user_data = json.dumps(user_data) + + results = helper_test_http_method(client, 'put', url, user_data, users) + assert results == [401, 200, 403, 200] + + +def test_user_delete(client, data): + url = reverse('users-detail', kwargs={"pk": data.registered_user.pk}) + + users = [ + None, + data.other_user, + data.registered_user, + ] + + results = helper_test_http_method(client, 'delete', url, None, users) + assert results == [401, 404, 204] + + +def test_user_list(client, data): + url = reverse('users-list') + + response = client.get(url) + users_data = json.loads(response.content.decode('utf-8')) + assert len(users_data) == 0 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + users_data = json.loads(response.content.decode('utf-8')) + assert len(users_data) == 1 + assert response.status_code == 200 + + client.login(data.other_user) + + response = client.get(url) + users_data = json.loads(response.content.decode('utf-8')) + assert len(users_data) == 1 + assert response.status_code == 200 + + client.login(data.superuser) + + response = client.get(url) + users_data = json.loads(response.content.decode('utf-8')) + assert len(users_data) == 3 + assert response.status_code == 200 + + +def test_user_create(client, data): + url = reverse('users-list') + + users = [ + None, + ] + + create_data = json.dumps({ + "username": "test", + "full_name": "test", + }) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [405] + + +def test_user_patch(client, data): + url = reverse('users-detail', kwargs={"pk": data.registered_user.pk}) + + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + + patch_data = json.dumps({"full_name": "test"}) + results = helper_test_http_method(client, 'patch', url, patch_data, users) + assert results == [401, 200, 404, 200] + + +def test_user_action_change_password(client, data): + url = reverse('users-change-password') + + data.registered_user.set_password("test-current-password") + data.registered_user.save() + data.other_user.set_password("test-current-password") + data.other_user.save() + data.superuser.set_password("test-current-password") + data.superuser.save() + + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + + post_data = json.dumps({"current_password": "test-current-password", "password": "test-password"}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 204, 204, 204] + + +def test_user_action_change_avatar(client, data): + url = reverse('users-change-avatar') + + with NamedTemporaryFile() as avatar: + avatar.write(DUMMY_BMP_DATA) + avatar.seek(0) + + post_data = { + 'avatar': avatar + } + + client.logout() + response = client.post(url, post_data) + assert response.status_code == 401 + + avatar.seek(0) + client.login(data.registered_user) + response = client.post(url, post_data) + assert response.status_code == 200 + + avatar.seek(0) + client.login(data.other_user) + response = client.post(url, post_data) + assert response.status_code == 200 + + avatar.seek(0) + client.login(data.superuser) + response = client.post(url, post_data) + assert response.status_code == 200 + + +def test_user_action_remove_avatar(client, data): + url = reverse('users-remove-avatar') + + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + + results = helper_test_http_method(client, 'post', url, None, users) + assert results == [401, 200, 200, 200] + + +def test_user_action_change_password_from_recovery(client, data): + url = reverse('users-change-password-from-recovery') + + new_user = f.UserFactory(token="test-token") + + def reset_token(): + new_user.token = "test-token" + new_user.save() + + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + + patch_data = json.dumps({"password": "test-password", "token": "test-token"}) + results = helper_test_http_method(client, 'post', url, patch_data, users, reset_token) + assert results == [204, 204, 204, 204] + + +def test_user_action_password_recovery(client, data): + url = reverse('users-password-recovery') + + f.UserFactory.create(username="test") + + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + + patch_data = json.dumps({"username": "test"}) + results = helper_test_http_method(client, 'post', url, patch_data, users) + assert results == [200, 200, 200, 200] + + +def test_user_action_change_email(client, data): + url = reverse('users-change-email') + + def after_each_request(): + data.registered_user.email_token = "test-token" + data.registered_user.new_email = "new@email.com" + data.registered_user.save() + + users = [ + None, + data.registered_user, + data.other_user, + ] + + patch_data = json.dumps({"email_token": "test-token"}) + after_each_request() + results = helper_test_http_method(client, 'post', url, patch_data, users, after_each_request=after_each_request) + assert results == [204, 204, 204] + + +def test_user_list_watched(client, data): + url = reverse('users-watched', kwargs={"pk": data.registered_user.pk}) + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [200, 200, 200, 200] + + +def test_user_list_liked(client, data): + url = reverse('users-liked', kwargs={"pk": data.registered_user.pk}) + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [200, 200, 200, 200] + + +def test_user_list_voted(client, data): + url = reverse('users-voted', kwargs={"pk": data.registered_user.pk}) + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [200, 200, 200, 200] diff --git a/tests/integration/resources_permissions/test_userstories_custom_attributes_resource.py b/tests/integration/resources_permissions/test_userstories_custom_attributes_resource.py new file mode 100644 index 000000000..b7a7666fe --- /dev/null +++ b/tests/integration/resources_permissions/test_userstories_custom_attributes_resource.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.custom_attributes import serializers +from taiga.permissions.choices import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS) + + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.private_project2, + role__permissions=[]) + + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + email=m.project_member_without_perms.email, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_userstory_ca = f.UserStoryCustomAttributeFactory(project=m.public_project) + m.private_userstory_ca1 = f.UserStoryCustomAttributeFactory(project=m.private_project1) + m.private_userstory_ca2 = f.UserStoryCustomAttributeFactory(project=m.private_project2) + m.blocked_userstory_ca = f.UserStoryCustomAttributeFactory(project=m.blocked_project) + + m.public_user_story = f.UserStoryFactory(project=m.public_project, + status__project=m.public_project) + m.private_user_story1 = f.UserStoryFactory(project=m.private_project1, + status__project=m.private_project1) + m.private_user_story2 = f.UserStoryFactory(project=m.private_project2, + status__project=m.private_project2) + m.blocked_user_story = f.UserStoryFactory(project=m.blocked_project, + status__project=m.blocked_project) + + m.public_user_story_cav = m.public_user_story.custom_attributes_values + m.private_user_story_cav1 = m.private_user_story1.custom_attributes_values + m.private_user_story_cav2 = m.private_user_story2.custom_attributes_values + m.blocked_user_story_cav = m.blocked_user_story.custom_attributes_values + + return m + + +######################################################### +# User Story Custom Attribute +######################################################### + +def test_userstory_custom_attribute_retrieve(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.pk}) + blocked_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.blocked_userstory_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_userstory_custom_attribute_create(client, data): + public_url = reverse('userstory-custom-attributes-list') + private1_url = reverse('userstory-custom-attributes-list') + private2_url = reverse('userstory-custom-attributes-list') + blocked_url = reverse('userstory-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + userstory_ca_data = {"name": "test-new", "project": data.public_project.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', public_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + userstory_ca_data = {"name": "test-new", "project": data.private_project1.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', private1_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + userstory_ca_data = {"name": "test-new", "project": data.private_project2.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', private2_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + userstory_ca_data = {"name": "test-new", "project": data.blocked_project.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', blocked_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_userstory_custom_attribute_update(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.pk}) + blocked_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.blocked_userstory_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.public_userstory_ca).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', public_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.private_userstory_ca1).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', private1_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.private_userstory_ca2).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', private2_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.blocked_userstory_ca).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', blocked_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 451] + + +def test_userstory_custom_attribute_delete(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.pk}) + blocked_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.blocked_userstory_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 403, 451] + + +def test_userstory_custom_attribute_list(client, data): + url = reverse('userstory-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 4 + assert response.status_code == 200 + + +def test_userstory_custom_attribute_patch(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.pk}) + blocked_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.blocked_userstory_ca.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', blocked_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 451] + + +def test_userstory_custom_attribute_action_bulk_update_order(client, data): + url = reverse('userstory-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.blocked_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 451] + + +######################################################### +# UserStory Custom Attribute +######################################################### + + +def test_userstory_custom_attributes_values_retrieve(client, data): + public_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story2.pk}) + blocked_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_userstory_custom_attributes_values_update(client, data): + public_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story2.pk}) + blocked_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.blocked_user_story.pk}) + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + user_story_data = serializers.UserStoryCustomAttributesValuesSerializer(data.public_user_story_cav).data + user_story_data["attributes_values"] = {str(data.public_userstory_ca.pk): "test"} + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', public_url, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = serializers.UserStoryCustomAttributesValuesSerializer(data.private_user_story_cav1).data + user_story_data["attributes_values"] = {str(data.private_userstory_ca1.pk): "test"} + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url1, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = serializers.UserStoryCustomAttributesValuesSerializer(data.private_user_story_cav2).data + user_story_data["attributes_values"] = {str(data.private_userstory_ca2.pk): "test"} + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url2, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = serializers.UserStoryCustomAttributesValuesSerializer(data.blocked_user_story_cav).data + user_story_data["attributes_values"] = {str(data.blocked_userstory_ca.pk): "test"} + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', blocked_url, user_story_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_userstory_custom_attributes_values_patch(client, data): + public_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story2.pk}) + blocked_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.blocked_user_story.pk}) + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_userstory_ca.pk): "test"}, + "version": data.public_user_story.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_userstory_ca1.pk): "test"}, + "version": data.private_user_story1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.private_userstory_ca2.pk): "test"}, + "version": data.private_user_story2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"attributes_values": {str(data.blocked_userstory_ca.pk): "test"}, + "version": data.blocked_user_story.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py new file mode 100644 index 000000000..8cdff3391 --- /dev/null +++ b/tests/integration/resources_permissions/test_userstories_resources.py @@ -0,0 +1,950 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.projects.userstories.models import UserStory +from taiga.projects.userstories.serializers import UserStorySerializer +from taiga.projects.userstories.utils import attach_extra_info as attach_userstory_extra_info +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.occ import OCCResourceMixin + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals +from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_function(function): + disconnect_signals() + + +def teardown_function(function): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + + m.public_points = f.PointsFactory() + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)) + ["comment_us"], + owner=m.project_owner, + userstories_csv_uuid=uuid.uuid4().hex, + default_points=m.public_points) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + + m.private_points1 = f.PointsFactory() + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner, + userstories_csv_uuid=uuid.uuid4().hex, + 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=[], + public_permissions=[], + owner=m.project_owner, + userstories_csv_uuid=uuid.uuid4().hex, + default_points=m.private_points2) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + + m.blocked_points = f.PointsFactory() + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + userstories_csv_uuid=uuid.uuid4().hex, + blocked_code=project_choices.BLOCKED_BY_STAFF, + default_points=m.blocked_points) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) + + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_user_story = f.UserStoryFactory(project=m.public_project, + milestone__project=m.public_project, + status__project=m.public_project) + m.public_user_story = attach_userstory_extra_info(UserStory.objects.all()).get(id=m.public_user_story.id) + + m.private_user_story1 = f.UserStoryFactory(project=m.private_project1, + milestone__project=m.private_project1, + status__project=m.private_project1) + m.private_user_story1 = attach_userstory_extra_info(UserStory.objects.all()).get(id=m.private_user_story1.id) + + m.private_user_story2 = f.UserStoryFactory(project=m.private_project2, + milestone__project=m.private_project2, + status__project=m.private_project2) + m.private_user_story2 = attach_userstory_extra_info(UserStory.objects.all()).get(id=m.private_user_story2.id) + + m.blocked_user_story = f.UserStoryFactory(project=m.blocked_project, + milestone__project=m.blocked_project, + status__project=m.blocked_project) + m.blocked_user_story = attach_userstory_extra_info(UserStory.objects.all()).get(id=m.blocked_user_story.id) + + return m + + +def test_user_story_list(client, data): + url = reverse('userstories-list') + + response = client.get(url) + userstories_data = json.loads(response.content.decode('utf-8')) + assert len(userstories_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + userstories_data = json.loads(response.content.decode('utf-8')) + assert len(userstories_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + userstories_data = json.loads(response.content.decode('utf-8')) + assert len(userstories_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + userstories_data = json.loads(response.content.decode('utf-8')) + assert len(userstories_data) == 4 + assert response.status_code == 200 + + +def test_user_story_retrieve(client, data): + public_url = reverse('userstories-detail', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-detail', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-detail', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-detail', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_user_story_create(client, data): + url = reverse('userstories-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({"subject": "test", "ref": 1, "project": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({"subject": "test", "ref": 2, "project": data.private_project1.pk}) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({"subject": "test", "ref": 3, "project": data.private_project2.pk}) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({"subject": "test", "ref": 4, "project": data.blocked_project.pk}) + results = helper_test_http_method(client, 'post', url, create_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_put_update(client, data): + public_url = reverse('userstories-detail', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-detail', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-detail', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-detail', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + user_story_data = UserStorySerializer(data.public_user_story).data + del user_story_data["points"] + user_story_data["subject"] = "test" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', public_url, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = UserStorySerializer(data.private_user_story1).data + del user_story_data["points"] + user_story_data["subject"] = "test" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url1, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = UserStorySerializer(data.private_user_story2).data + del user_story_data["points"] + user_story_data["subject"] = "test" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url2, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = UserStorySerializer(data.blocked_user_story).data + del user_story_data["points"] + user_story_data["subject"] = "test" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', blocked_url, user_story_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_put_comment(client, data): + public_url = reverse('userstories-detail', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-detail', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-detail', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-detail', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + user_story_data = UserStorySerializer(data.public_user_story).data + del user_story_data["points"] + user_story_data["comment"] = "test comment" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', public_url, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = UserStorySerializer(data.private_user_story1).data + del user_story_data["points"] + user_story_data["comment"] = "test comment" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url1, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = UserStorySerializer(data.private_user_story2).data + del user_story_data["points"] + user_story_data["comment"] = "test comment" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url2, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = UserStorySerializer(data.blocked_user_story).data + del user_story_data["points"] + user_story_data["comment"] = "test comment" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', blocked_url, user_story_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_put_update_and_comment(client, data): + public_url = reverse('userstories-detail', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-detail', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-detail', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-detail', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + user_story_data = UserStorySerializer(data.public_user_story).data + del user_story_data["points"] + user_story_data["subject"] = "test" + user_story_data["comment"] = "test comment" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', public_url, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = UserStorySerializer(data.private_user_story1).data + del user_story_data["points"] + user_story_data["subject"] = "test" + user_story_data["comment"] = "test comment" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url1, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = UserStorySerializer(data.private_user_story2).data + del user_story_data["points"] + user_story_data["subject"] = "test" + user_story_data["comment"] = "test comment" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url2, user_story_data, users) + assert results == [401, 403, 403, 200, 200] + + user_story_data = UserStorySerializer(data.blocked_user_story).data + del user_story_data["points"] + user_story_data["subject"] = "test" + user_story_data["comment"] = "test comment" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', blocked_url, user_story_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_put_update_with_project_change(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + user3 = f.UserFactory.create() + user4 = f.UserFactory.create() + project1 = f.ProjectFactory() + project2 = f.ProjectFactory() + + us_status1 = f.UserStoryStatusFactory.create(project=project1) + us_status2 = f.UserStoryStatusFactory.create(project=project2) + points1 = f.PointsFactory.create(project=project1) + points2 = f.PointsFactory.create(project=project2) + + project1.default_us_status = us_status1 + project2.default_us_status = us_status2 + project1.default_points = points1 + project2.default_points = points2 + + project1.save() + project2.save() + + project1 = attach_project_extra_info(Project.objects.all()).get(id=project1.id) + project2 = attach_project_extra_info(Project.objects.all()).get(id=project2.id) + + f.MembershipFactory(project=project1, + user=user1, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user1, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project1, + user=user2, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user3, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + us = f.UserStoryFactory.create(project=project1) + us = attach_userstory_extra_info(UserStory.objects.all()).get(id=us.id) + + url = reverse('userstories-detail', kwargs={"pk": us.pk}) + + # Test user with permissions in both projects + client.login(user1) + + us_data = UserStorySerializer(us).data + us_data["project"] = project2.id + del us_data["points"] + us_data = json.dumps(us_data) + + response = client.put(url, data=us_data, content_type="application/json") + + assert response.status_code == 200 + + us.project = project1 + us.save() + + # Test user with permissions in only origin project + client.login(user2) + + us_data = UserStorySerializer(us).data + us_data["project"] = project2.id + del us_data["points"] + us_data = json.dumps(us_data) + + response = client.put(url, data=us_data, content_type="application/json") + + assert response.status_code == 403 + + us.project = project1 + us.save() + + # Test user with permissions in only destionation project + client.login(user3) + + us_data = UserStorySerializer(us).data + us_data["project"] = project2.id + del us_data["points"] + us_data = json.dumps(us_data) + + response = client.put(url, data=us_data, content_type="application/json") + + assert response.status_code == 403 + + us.project = project1 + us.save() + + # Test user without permissions in the projects + client.login(user4) + + us_data = UserStorySerializer(us).data + us_data["project"] = project2.id + del us_data["points"] + us_data = json.dumps(us_data) + + response = client.put(url, data=us_data, content_type="application/json") + + assert response.status_code == 403 + + us.project = project1 + us.save() + + +def test_user_story_patch_update(client, data): + public_url = reverse('userstories-detail', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-detail', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-detail', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-detail', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"subject": "test", "version": data.public_user_story.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_user_story1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.private_user_story2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"subject": "test", "version": data.blocked_user_story.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_patch_comment(client, data): + public_url = reverse('userstories-detail', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-detail', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-detail', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-detail', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"comment": "test comment", "version": data.public_user_story.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 200, 200, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_user_story1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_user_story2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.blocked_user_story.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_patch_update_and_comment(client, data): + public_url = reverse('userstories-detail', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-detail', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-detail', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-detail', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.public_user_story.version + }) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_user_story1.version + }) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.private_user_story2.version + }) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "subject": "test", + "comment": "test comment", + "version": data.blocked_user_story.version + }) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_delete(client, data): + public_url = reverse('userstories-detail', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-detail', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-detail', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-detail', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_user_story_action_bulk_create(client, data): + url = reverse('userstories-bulk-create') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + bulk_data = json.dumps({"bulk_stories": "test1\ntest2", "project_id": data.public_user_story.project.pk}) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({"bulk_stories": "test1\ntest2", "project_id": data.private_user_story1.project.pk}) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({"bulk_stories": "test1\ntest2", "project_id": data.private_user_story2.project.pk}) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 200, 200] + + bulk_data = json.dumps({"bulk_stories": "test1\ntest2", "project_id": data.blocked_user_story.project.pk}) + results = helper_test_http_method(client, 'post', url, bulk_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_user_story_action_bulk_update_order(client, data): + url = reverse('userstories-bulk-update-backlog-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_userstories": [data.public_user_story.id], + "project_id": data.public_project.pk, + "after_userstory_id": None, + "milestone_id": None, + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 200, 200] + + post_data = json.dumps({ + "bulk_userstories": [data.private_user_story1.id], + "project_id": data.private_project1.pk, + "after_userstory_id": None, + "milestone_id": None, + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 200, 200] + + post_data = json.dumps({ + "bulk_userstories": [data.private_user_story2.id], + "project_id": data.private_project2.pk, + "after_userstory_id": None, + "milestone_id": None, + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 200, 200] + + post_data = json.dumps({ + "bulk_userstories": [data.blocked_user_story.id], + "project_id": data.blocked_project.pk, + "after_userstory_id": None, + "milestone_id": None, + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + 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}) + private_url2 = reverse('userstories-upvote', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-upvote', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_user_story_action_downvote(client, data): + public_url = reverse('userstories-downvote', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-downvote', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-downvote', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-downvote', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_user_story_voters_list(client, data): + public_url = reverse('userstory-voters-list', kwargs={"resource_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-voters-list', kwargs={"resource_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-voters-list', kwargs={"resource_id": data.private_user_story2.pk}) + blocked_url = reverse('userstory-voters-list', kwargs={"resource_id": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_user_story_voters_retrieve(client, data): + add_vote(data.public_user_story, data.project_owner) + public_url = reverse('userstory-voters-detail', kwargs={"resource_id": data.public_user_story.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_user_story1, data.project_owner) + private_url1 = reverse('userstory-voters-detail', kwargs={"resource_id": data.private_user_story1.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_user_story2, data.project_owner) + private_url2 = reverse('userstory-voters-detail', kwargs={"resource_id": data.private_user_story2.pk, + "pk": data.project_owner.pk}) + + add_vote(data.blocked_user_story, data.project_owner) + blocked_url = reverse('userstory-voters-detail', kwargs={"resource_id": data.blocked_user_story.pk, + "pk": data.project_owner.pk}) + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_user_story_action_watch(client, data): + public_url = reverse('userstories-watch', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-watch', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-watch', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-watch', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_user_story_action_unwatch(client, data): + public_url = reverse('userstories-unwatch', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-unwatch', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-unwatch', kwargs={"pk": data.private_user_story2.pk}) + blocked_url = reverse('userstories-unwatch', kwargs={"pk": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_userstory_watchers_list(client, data): + public_url = reverse('userstory-watchers-list', kwargs={"resource_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-watchers-list', kwargs={"resource_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-watchers-list', kwargs={"resource_id": data.private_user_story2.pk}) + blocked_url = reverse('userstory-watchers-list', kwargs={"resource_id": data.blocked_user_story.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_userstory_watchers_retrieve(client, data): + add_watcher(data.public_user_story, data.project_owner) + public_url = reverse('userstory-watchers-detail', kwargs={"resource_id": data.public_user_story.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_user_story1, data.project_owner) + private_url1 = reverse('userstory-watchers-detail', kwargs={"resource_id": data.private_user_story1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_user_story2, data.project_owner) + private_url2 = reverse('userstory-watchers-detail', kwargs={"resource_id": data.private_user_story2.pk, + "pk": data.project_owner.pk}) + add_watcher(data.blocked_user_story, data.project_owner) + blocked_url = reverse('userstory-watchers-detail', kwargs={"resource_id": data.blocked_user_story.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_user_stories_action_csv(client, data): + url = reverse('userstories-csv') + csv_public_uuid = data.public_project.userstories_csv_uuid + csv_private1_uuid = data.private_project1.userstories_csv_uuid + csv_private2_uuid = data.private_project1.userstories_csv_uuid + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) + assert results == [200, 200, 200, 200, 200] diff --git a/tests/integration/resources_permissions/test_webhooks_resources.py b/tests/integration/resources_permissions/test_webhooks_resources.py new file mode 100644 index 000000000..f2d553953 --- /dev/null +++ b/tests/integration/resources_permissions/test_webhooks_resources.py @@ -0,0 +1,385 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.projects import choices as project_choices +from taiga.webhooks.serializers import WebhookSerializer +from taiga.webhooks.models import Webhook + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + + m.project1 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + + f.MembershipFactory(project=m.project1, + user=m.project_owner, + is_admin=True) + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.webhook1 = f.WebhookFactory(project=m.project1) + m.webhooklog1 = f.WebhookLogFactory(webhook=m.webhook1) + m.webhook2 = f.WebhookFactory(project=m.project2) + m.webhooklog2 = f.WebhookLogFactory(webhook=m.webhook2) + m.blocked_webhook = f.WebhookFactory(project=m.blocked_project) + m.blocked_webhooklog = f.WebhookLogFactory(webhook=m.blocked_webhook) + + return m + + +def test_webhook_retrieve(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + blocked_url = reverse('webhooks-detail', kwargs={"pk": data.blocked_webhook.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', url1, None, users) + assert results == [401, 403, 200] + results = helper_test_http_method(client, 'get', url2, None, users) + assert results == [401, 403, 403] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 200] + + +def test_webhook_update(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + blocked_url = reverse('webhooks-detail', kwargs={"pk": data.blocked_webhook.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + webhook_data = WebhookSerializer(data.webhook1).data + webhook_data["key"] = "test" + webhook_data = json.dumps(webhook_data) + results = helper_test_http_method(client, 'put', url1, webhook_data, users) + assert results == [401, 403, 200] + + webhook_data = WebhookSerializer(data.webhook2).data + webhook_data["key"] = "test" + webhook_data = json.dumps(webhook_data) + results = helper_test_http_method(client, 'put', url2, webhook_data, users) + assert results == [401, 403, 403] + + webhook_data = WebhookSerializer(data.blocked_webhook).data + webhook_data["key"] = "test" + webhook_data = json.dumps(webhook_data) + results = helper_test_http_method(client, 'put', blocked_url, webhook_data, users) + assert results == [401, 403, 451] + + +def test_webhook_delete(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + blocked_url = reverse('webhooks-detail', kwargs={"pk": data.blocked_webhook.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + results = helper_test_http_method(client, 'delete', url1, None, users) + assert results == [401, 403, 204] + results = helper_test_http_method(client, 'delete', url2, None, users) + assert results == [401, 403, 403] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 451] + + +def test_webhook_list(client, data): + url = reverse('webhooks-list') + + response = client.get(url) + webhooks_data = json.loads(response.content.decode('utf-8')) + assert len(webhooks_data) == 0 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + webhooks_data = json.loads(response.content.decode('utf-8')) + assert len(webhooks_data) == 0 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + webhooks_data = json.loads(response.content.decode('utf-8')) + assert len(webhooks_data) == 2 + assert response.status_code == 200 + + +def test_webhook_create(client, data): + url = reverse('webhooks-list') + + users = [ + None, + data.registered_user, + data.project_owner + ] + + create_data = json.dumps({ + "name": "Test", + "url": "http://test.com", + "key": "test", + "project": data.project1.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Webhook.objects.all().delete()) + assert results == [401, 403, 201] + + create_data = json.dumps({ + "name": "Test", + "url": "http://test.com", + "key": "test", + "project": data.project2.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Webhook.objects.all().delete()) + assert results == [401, 403, 403] + + create_data = json.dumps({ + "name": "Test", + "url": "http://test.com", + "key": "test", + "project": data.blocked_project.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Webhook.objects.all().delete()) + assert results == [401, 403, 451] + + +def test_webhook_patch(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + blocked_url = reverse('webhooks-detail', kwargs={"pk": data.blocked_webhook.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + patch_data = json.dumps({"key": "test"}) + results = helper_test_http_method(client, 'patch', url1, patch_data, users) + assert results == [401, 403, 200] + + patch_data = json.dumps({"key": "test"}) + results = helper_test_http_method(client, 'patch', url2, patch_data, users) + assert results == [401, 403, 403] + + patch_data = json.dumps({"key": "test"}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 451] + + +def test_webhook_action_test(client, data): + url1 = reverse('webhooks-test', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-test', kwargs={"pk": data.webhook2.pk}) + blocked_url = reverse('webhooks-test', kwargs={"pk": data.blocked_webhook.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + _send_request_mock.return_value = data.webhooklog1 + results = helper_test_http_method(client, 'post', url1, None, users) + assert results == [401, 404, 200] + assert _send_request_mock.called is True + + with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + _send_request_mock.return_value = data.webhooklog1 + results = helper_test_http_method(client, 'post', url2, None, users) + assert results == [401, 404, 404] + assert _send_request_mock.called is False + + with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + _send_request_mock.return_value = data.webhooklog1 + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 451] + assert _send_request_mock.called is False + + +def test_webhooklogs_list(client, data): + url = reverse('webhooklogs-list') + + response = client.get(url) + webhooklogs_data = json.loads(response.content.decode('utf-8')) + assert len(webhooklogs_data) == 0 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + webhooklogs_data = json.loads(response.content.decode('utf-8')) + assert len(webhooklogs_data) == 0 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + webhooklogs_data = json.loads(response.content.decode('utf-8')) + assert len(webhooklogs_data) == 2 + assert response.status_code == 200 + + +def test_webhooklogs_retrieve(client, data): + url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk}) + blocked_url = reverse('webhooks-detail', kwargs={"pk": data.blocked_webhook.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', url1, None, users) + assert results == [401, 403, 200] + + results = helper_test_http_method(client, 'get', url2, None, users) + assert results == [401, 403, 403] + + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 200] + + +def test_webhooklogs_create(client, data): + url1 = reverse('webhooklogs-list') + url2 = reverse('webhooklogs-list') + blocked_url = reverse('webhooklogs-list') + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'post', url2, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [405, 405, 405] + + +def test_webhooklogs_delete(client, data): + url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk}) + blocked_url = reverse('webhooklogs-detail', kwargs={"pk": data.blocked_webhooklog.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'delete', url2, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [405, 405, 405] + + +def test_webhooklogs_update(client, data): + url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk}) + blocked_url = reverse('webhooklogs-detail', kwargs={"pk": data.blocked_webhooklog.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'put', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'put', url2, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'put', blocked_url, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'patch', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'patch', url2, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'patch', blocked_url, None, users) + assert results == [405, 405, 405] + + +def test_webhooklogs_action_resend(client, data): + url1 = reverse('webhooklogs-resend', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-resend', kwargs={"pk": data.webhooklog2.pk}) + blocked_url = reverse('webhooklogs-resend', kwargs={"pk": data.blocked_webhooklog.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', url1, None, users) + assert results == [401, 404, 200] + + results = helper_test_http_method(client, 'post', url2, None, users) + assert results == [401, 404, 404] + + results = helper_test_http_method(client, 'post', blocked_url, None, users) + assert results == [401, 404, 451] diff --git a/tests/integration/resources_permissions/test_wiki_resources.py b/tests/integration/resources_permissions/test_wiki_resources.py new file mode 100644 index 000000000..b611453c9 --- /dev/null +++ b/tests/integration/resources_permissions/test_wiki_resources.py @@ -0,0 +1,879 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects import choices as project_choices +from taiga.projects.notifications.services import add_watcher +from taiga.projects.occ import OCCResourceMixin +from taiga.projects.wiki.serializers import WikiPageSerializer, WikiLinkSerializer +from taiga.projects.wiki.models import WikiPage, WikiLink + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + blocked_code=project_choices.BLOCKED_BY_STAFF) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory(project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=m.blocked_project, + user=m.project_member_without_perms, + role__project=m.blocked_project, + role__permissions=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_admin=True) + + f.MembershipFactory(project=m.blocked_project, + user=m.project_owner, + is_admin=True) + + m.public_wiki_page = f.WikiPageFactory(project=m.public_project) + m.private_wiki_page1 = f.WikiPageFactory(project=m.private_project1) + m.private_wiki_page2 = f.WikiPageFactory(project=m.private_project2) + m.blocked_wiki_page = f.WikiPageFactory(project=m.blocked_project) + + m.public_wiki_link = f.WikiLinkFactory(project=m.public_project) + m.private_wiki_link1 = f.WikiLinkFactory(project=m.private_project1) + m.private_wiki_link2 = f.WikiLinkFactory(project=m.private_project2) + m.blocked_wiki_link = f.WikiLinkFactory(project=m.blocked_project) + + return m + + +############################################## +## WIKI PAGES +############################################## + +def test_wiki_page_list(client, data): + url = reverse('wiki-list') + + response = client.get(url) + wiki_pages_data = json.loads(response.content.decode('utf-8')) + assert len(wiki_pages_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + wiki_pages_data = json.loads(response.content.decode('utf-8')) + assert len(wiki_pages_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + wiki_pages_data = json.loads(response.content.decode('utf-8')) + assert len(wiki_pages_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + wiki_pages_data = json.loads(response.content.decode('utf-8')) + assert len(wiki_pages_data) == 4 + assert response.status_code == 200 + + +def test_wiki_page_retrieve(client, data): + public_url = reverse('wiki-detail', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-detail', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_wiki_page_create(client, data): + url = reverse('wiki-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({ + "content": "test", + "slug": "test", + "project": data.public_project.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: WikiPage.objects.all().delete()) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "content": "test", + "slug": "test", + "project": data.private_project1.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: WikiPage.objects.all().delete()) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "content": "test", + "slug": "test", + "project": data.private_project2.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: WikiPage.objects.all().delete()) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "content": "test", + "slug": "test", + "project": data.blocked_project.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: WikiPage.objects.all().delete()) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_page_put_update(client, data): + public_url = reverse('wiki-detail', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-detail', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + wiki_page_data = WikiPageSerializer(data.public_wiki_page).data + wiki_page_data["content"] = "test" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', public_url, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_page_data = WikiPageSerializer(data.private_wiki_page1).data + wiki_page_data["content"] = "test" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', private_url1, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_page_data = WikiPageSerializer(data.private_wiki_page2).data + wiki_page_data["content"] = "test" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', private_url2, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_page_data = WikiPageSerializer(data.blocked_wiki_page).data + wiki_page_data["content"] = "test" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', blocked_url, wiki_page_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_page_put_comment(client, data): + public_url = reverse('wiki-detail', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-detail', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + wiki_page_data = WikiPageSerializer(data.public_wiki_page).data + wiki_page_data["comment"] = "test comment" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', public_url, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_page_data = WikiPageSerializer(data.private_wiki_page1).data + wiki_page_data["comment"] = "test comment" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', private_url1, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_page_data = WikiPageSerializer(data.private_wiki_page2).data + wiki_page_data["comment"] = "test comment" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', private_url2, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_page_data = WikiPageSerializer(data.blocked_wiki_page).data + wiki_page_data["comment"] = "test comment" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', blocked_url, wiki_page_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_page_put_update_and_comment(client, data): + public_url = reverse('wiki-detail', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-detail', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + wiki_page_data = WikiPageSerializer(data.public_wiki_page).data + wiki_page_data["slug"] = "test" + wiki_page_data["comment"] = "test comment" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', public_url, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_page_data = WikiPageSerializer(data.private_wiki_page1).data + wiki_page_data["slug"] = "test" + wiki_page_data["comment"] = "test comment" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', private_url1, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_page_data = WikiPageSerializer(data.private_wiki_page2).data + wiki_page_data["slug"] = "test" + wiki_page_data["comment"] = "test comment" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', private_url2, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_page_data = WikiPageSerializer(data.blocked_wiki_page).data + wiki_page_data["slug"] = "test" + wiki_page_data["comment"] = "test comment" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', blocked_url, wiki_page_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_page_put_update_with_project_change(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + user3 = f.UserFactory.create() + user4 = f.UserFactory.create() + project1 = f.ProjectFactory() + project2 = f.ProjectFactory() + + membership1 = f.MembershipFactory(project=project1, + user=user1, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + membership2 = f.MembershipFactory(project=project2, + user=user1, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + membership3 = f.MembershipFactory(project=project1, + user=user2, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + membership4 = f.MembershipFactory(project=project2, + user=user3, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + wiki_page = f.WikiPageFactory.create(project=project1) + + url = reverse('wiki-detail', kwargs={"pk": wiki_page.pk}) + + # Test user with permissions in both projects + client.login(user1) + + wiki_page_data = WikiPageSerializer(wiki_page).data + wiki_page_data["project"] = project2.id + wiki_page_data = json.dumps(wiki_page_data) + + response = client.put(url, data=wiki_page_data, content_type="application/json") + + assert response.status_code == 200 + + wiki_page.project = project1 + wiki_page.save() + + # Test user with permissions in only origin project + client.login(user2) + + wiki_page_data = WikiPageSerializer(wiki_page).data + wiki_page_data["project"] = project2.id + wiki_page_data = json.dumps(wiki_page_data) + + response = client.put(url, data=wiki_page_data, content_type="application/json") + + assert response.status_code == 403 + + wiki_page.project = project1 + wiki_page.save() + + # Test user with permissions in only destionation project + client.login(user3) + + wiki_page_data = WikiPageSerializer(wiki_page).data + wiki_page_data["project"] = project2.id + wiki_page_data = json.dumps(wiki_page_data) + + response = client.put(url, data=wiki_page_data, content_type="application/json") + + assert response.status_code == 403 + + wiki_page.project = project1 + wiki_page.save() + + # Test user without permissions in the projects + client.login(user4) + + wiki_page_data = WikiPageSerializer(wiki_page).data + wiki_page_data["project"] = project2.id + wiki_page_data = json.dumps(wiki_page_data) + + response = client.put(url, data=wiki_page_data, content_type="application/json") + + assert response.status_code == 403 + + wiki_page.project = project1 + wiki_page.save() + + +def test_wiki_page_patch_update(client, data): + public_url = reverse('wiki-detail', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-detail', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"content": "test", "version": data.public_wiki_page.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"content": "test", "version": data.private_wiki_page2.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"content": "test", "version": data.private_wiki_page2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"content": "test", "version": data.blocked_wiki_page.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_page_patch_comment(client, data): + public_url = reverse('wiki-detail', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-detail', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"comment": "test comment", "version": data.public_wiki_page.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_wiki_page2.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.private_wiki_page2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"comment": "test comment", "version": data.blocked_wiki_page.version}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_page_patch_update_and_comment(client, data): + public_url = reverse('wiki-detail', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-detail', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({ + "content": "test", + "comment": "test comment", + "version": data.public_wiki_page.version + }) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "content": "test", + "comment": "test comment", + "version": data.private_wiki_page2.version + }) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "content": "test", + "comment": "test comment", + "version": data.private_wiki_page2.version + }) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({ + "content": "test", + "comment": "test comment", + "version": data.blocked_wiki_page.version + }) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_page_delete(client, data): + public_url = reverse('wiki-detail', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-detail', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-detail', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] + + +def test_wiki_page_action_render(client, data): + url = reverse('wiki-render') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({"content": "test", "project_id": data.public_project.pk}) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [200, 200, 200, 200, 200] + + +def test_wikipage_action_watch(client, data): + public_url = reverse('wiki-watch', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-watch', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-watch', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-watch', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_wikipage_action_unwatch(client, data): + public_url = reverse('wiki-unwatch', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-unwatch', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-unwatch', kwargs={"pk": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-unwatch', kwargs={"pk": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [401, 404, 404, 200, 200] + results = helper_test_http_method(client, 'post', blocked_url, "", users) + assert results == [401, 404, 404, 451, 451] + + +def test_wikipage_watchers_list(client, data): + public_url = reverse('wiki-watchers-list', kwargs={"resource_id": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-watchers-list', kwargs={"resource_id": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-watchers-list', kwargs={"resource_id": data.private_wiki_page2.pk}) + blocked_url = reverse('wiki-watchers-list', kwargs={"resource_id": data.blocked_wiki_page.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_wikipage_watchers_retrieve(client, data): + add_watcher(data.public_wiki_page, data.project_owner) + public_url = reverse('wiki-watchers-detail', kwargs={"resource_id": data.public_wiki_page.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_wiki_page1, data.project_owner) + private_url1 = reverse('wiki-watchers-detail', kwargs={"resource_id": data.private_wiki_page1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_wiki_page2, data.project_owner) + private_url2 = reverse('wiki-watchers-detail', kwargs={"resource_id": data.private_wiki_page2.pk, + "pk": data.project_owner.pk}) + add_watcher(data.blocked_wiki_page, data.project_owner) + blocked_url = reverse('wiki-watchers-detail', kwargs={"resource_id": data.blocked_wiki_page.pk, + "pk": data.project_owner.pk}) + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +############################################## +## WIKI LINKS +############################################## + +def test_wiki_link_list(client, data): + url = reverse('wiki-links-list') + + response = client.get(url) + wiki_links_data = json.loads(response.content.decode('utf-8')) + assert len(wiki_links_data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + wiki_links_data = json.loads(response.content.decode('utf-8')) + assert len(wiki_links_data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + + response = client.get(url) + wiki_links_data = json.loads(response.content.decode('utf-8')) + assert len(wiki_links_data) == 4 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + wiki_links_data = json.loads(response.content.decode('utf-8')) + assert len(wiki_links_data) == 4 + assert response.status_code == 200 + + +def test_wiki_link_retrieve(client, data): + public_url = reverse('wiki-links-detail', kwargs={"pk": data.public_wiki_link.pk}) + private_url1 = reverse('wiki-links-detail', kwargs={"pk": data.private_wiki_link1.pk}) + private_url2 = reverse('wiki-links-detail', kwargs={"pk": data.private_wiki_link2.pk}) + blocked_url = reverse('wiki-links-detail', kwargs={"pk": data.blocked_wiki_link.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + results = helper_test_http_method(client, 'get', blocked_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_wiki_link_create(client, data): + url = reverse('wiki-links-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + create_data = json.dumps({ + "title": "test", + "href": "test", + "project": data.public_project.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: WikiLink.objects.all().delete()) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "title": "test", + "href": "test", + "project": data.private_project1.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: WikiLink.objects.all().delete()) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "title": "test", + "href": "test", + "project": data.private_project2.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: WikiLink.objects.all().delete()) + assert results == [401, 403, 403, 201, 201] + + create_data = json.dumps({ + "title": "test", + "href": "test", + "project": data.blocked_project.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: WikiLink.objects.all().delete()) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_link_update(client, data): + public_url = reverse('wiki-links-detail', kwargs={"pk": data.public_wiki_link.pk}) + private_url1 = reverse('wiki-links-detail', kwargs={"pk": data.private_wiki_link1.pk}) + private_url2 = reverse('wiki-links-detail', kwargs={"pk": data.private_wiki_link2.pk}) + blocked_url = reverse('wiki-links-detail', kwargs={"pk": data.blocked_wiki_link.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + wiki_link_data = WikiLinkSerializer(data.public_wiki_link).data + wiki_link_data["title"] = "test" + wiki_link_data = json.dumps(wiki_link_data) + results = helper_test_http_method(client, 'put', public_url, wiki_link_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_link_data = WikiLinkSerializer(data.private_wiki_link1).data + wiki_link_data["title"] = "test" + wiki_link_data = json.dumps(wiki_link_data) + results = helper_test_http_method(client, 'put', private_url1, wiki_link_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_link_data = WikiLinkSerializer(data.private_wiki_link2).data + wiki_link_data["title"] = "test" + wiki_link_data = json.dumps(wiki_link_data) + results = helper_test_http_method(client, 'put', private_url2, wiki_link_data, users) + assert results == [401, 403, 403, 200, 200] + + wiki_link_data = WikiLinkSerializer(data.blocked_wiki_link).data + wiki_link_data["title"] = "test" + wiki_link_data = json.dumps(wiki_link_data) + results = helper_test_http_method(client, 'put', blocked_url, wiki_link_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_link_patch(client, data): + public_url = reverse('wiki-links-detail', kwargs={"pk": data.public_wiki_link.pk}) + private_url1 = reverse('wiki-links-detail', kwargs={"pk": data.private_wiki_link1.pk}) + private_url2 = reverse('wiki-links-detail', kwargs={"pk": data.private_wiki_link2.pk}) + blocked_url = reverse('wiki-links-detail', kwargs={"pk": data.blocked_wiki_link.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + patch_data = json.dumps({"title": "test"}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"title": "test"}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"title": "test"}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] + + patch_data = json.dumps({"title": "test"}) + results = helper_test_http_method(client, 'patch', blocked_url, patch_data, users) + assert results == [401, 403, 403, 451, 451] + + +def test_wiki_link_delete(client, data): + public_url = reverse('wiki-links-detail', kwargs={"pk": data.public_wiki_link.pk}) + private_url1 = reverse('wiki-links-detail', kwargs={"pk": data.private_wiki_link1.pk}) + private_url2 = reverse('wiki-links-detail', kwargs={"pk": data.private_wiki_link2.pk}) + blocked_url = reverse('wiki-links-detail', kwargs={"pk": data.blocked_wiki_link.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + ] + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url1, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private_url2, None, users) + assert results == [401, 403, 403, 204] + results = helper_test_http_method(client, 'delete', blocked_url, None, users) + assert results == [401, 403, 403, 451] diff --git a/tests/integration/test_application_tokens.py b/tests/integration/test_application_tokens.py new file mode 100644 index 000000000..778836e94 --- /dev/null +++ b/tests/integration/test_application_tokens.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from taiga.external_apps import models + + +from .. import factories as f + +import json +import pytest +pytestmark = pytest.mark.django_db + + +def test_own_tokens_listing(client): + user_1 = f.UserFactory.create() + user_2 = f.UserFactory.create() + token_1 = f.ApplicationTokenFactory(user=user_1) + token_2 = f.ApplicationTokenFactory(user=user_2) + url = reverse("application-tokens-list") + client.login(user_1) + response = client.json.get(url) + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0].get("id") == token_1.id + assert response.data[0].get("application").get("id") == token_1.application.id + + +def test_retrieve_existing_token_for_application(client): + token = f.ApplicationTokenFactory() + url = reverse("applications-token", args=[token.application.id]) + client.login(token.user) + response = client.json.get(url) + assert response.status_code == 200 + assert response.data.get("application").get("id") == token.application.id + + + +def test_retrieve_unexisting_token_for_application(client): + user = f.UserFactory.create() + url = reverse("applications-token", args=[-1]) + client.login(user) + response = client.json.get(url) + assert response.status_code == 404 + + +def test_token_authorize(client): + user = f.UserFactory.create() + application = f.ApplicationFactory() + url = reverse("application-tokens-authorize") + client.login(user) + + data = json.dumps({ + "application": application.id, + "state": "random-state" + }) + + response = client.json.post(url, data) + + assert response.status_code == 200 + assert response.data["state"] == "random-state" + auth_code_1 = response.data["auth_code"] + + response = client.json.post(url, data) + assert response.status_code == 200 + assert response.data["state"] == "random-state" + auth_code_2 = response.data["auth_code"] + assert auth_code_1 != auth_code_2 + + +def test_token_authorize_invalid_app(client): + user = f.UserFactory.create() + url = reverse("application-tokens-authorize") + client.login(user) + + data = json.dumps({ + "application": 33, + "state": "random-state" + }) + + response = client.json.post(url, data) + assert response.status_code == 404 + + +def test_token_validate(client): + user = f.UserFactory.create() + application = f.ApplicationFactory(next_url="http://next.url") + token = f.ApplicationTokenFactory(auth_code="test-auth-code", state="test-state", application=application) + url = reverse("application-tokens-validate") + client.login(user) + + data = { + "application": token.application.id, + "auth_code": "test-auth-code", + "state": "test-state" + } + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + + token = models.ApplicationToken.objects.get(id=token.id) + assert response.data["token"] == token.token + + +def test_token_validate_validated(client): + # Validating a validated token should update the token attribute + user = f.UserFactory.create() + application = f.ApplicationFactory(next_url="http://next.url") + token = f.ApplicationTokenFactory( + auth_code="test-auth-code", + state="test-state", + application=application, + token="existing-token") + + url = reverse("application-tokens-validate") + client.login(user) + + data = { + "application": token.application.id, + "auth_code": "test-auth-code", + "state": "test-state" + } + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + + token = models.ApplicationToken.objects.get(id=token.id) + assert token.token == "existing-token" diff --git a/tests/integration/test_attachments.py b/tests/integration/test_attachments.py new file mode 100644 index 000000000..8adf19b11 --- /dev/null +++ b/tests/integration/test_attachments.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.urls import reverse +from django.core.files.uploadedfile import SimpleUploadedFile + +from taiga.base.utils import json + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_create_user_story_attachment_without_file(client): + """ + Bug test "Don't create attachments without attached_file" + """ + us = f.UserStoryFactory.create() + f.MembershipFactory(project=us.project, user=us.owner, is_admin=True) + attachment_data = { + "description": "test", + "project": us.project_id, + } + + url = reverse('userstory-attachments-list') + + client.login(us.owner) + response = client.post(url, attachment_data) + assert response.status_code == 400 + + +def test_create_attachment_on_wrong_project(client): + issue1 = f.create_issue() + issue2 = f.create_issue(owner=issue1.owner) + f.MembershipFactory(project=issue1.project, user=issue1.owner, is_admin=True) + + assert issue1.owner == issue2.owner + assert issue1.project.owner == issue2.project.owner + + url = reverse("issue-attachments-list") + + data = {"description": "test", + "object_id": issue2.pk, + "project": issue1.project.id, + "attached_file": SimpleUploadedFile("test.txt", b"test")} + + client.login(issue1.owner) + response = client.post(url, data) + assert response.status_code == 400 + + +def test_create_attachment_with_long_file_name(client): + issue1 = f.create_issue() + f.MembershipFactory(project=issue1.project, user=issue1.owner, is_admin=True) + + url = reverse("issue-attachments-list") + + data = {"description": "test", + "object_id": issue1.pk, + "project": issue1.project.id, + "attached_file": SimpleUploadedFile(500*"x"+".txt", b"test")} + + client.login(issue1.owner) + response = client.post(url, data) + assert response.data["attached_file"].endswith("/"+100*"x"+".txt") + + +###################################### +# Sorting attachments +###################################### + +def test_api_update_orders_in_bulk_succeeds_moved_to_the_begining(client): + # + # -------- | | -------- + # att1 | MOVE: att2, att3 | att2 + # att2 | AFTER: bigining | att3 + # att3 | | att1 + # + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us = f.create_userstory(project=project) + + att1 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=1) + att2 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=2) + att3 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=3) + + url = reverse("userstory-attachments-bulk-update-order") + + data = { + "object_id": us.id, + "after_attachment_id": None, + "bulk_attachments": [att2.id, + att3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + att2.id, + att3.id, + att1.id, + ] + res = (us.attachments.filter(id__in=updated_ids) + .values("id", "order") + .order_by("order", "id")) + assert response.json() == list(res) + + att1.refresh_from_db() + att2.refresh_from_db() + att3.refresh_from_db() + assert att2.order == 1 + assert att3.order == 2 + assert att1.order == 3 + + +def test_api_update_orders_in_bulk_succeeds_moved_to_the_middle(client): + # + # -------- | | -------- + # att1 | MOVE: att3, att1 | att2 + # att2 | AFTER: att2 | att3 + # att3 | | att1 + # + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us = f.create_userstory(project=project) + + att1 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=1) + att2 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=2) + att3 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=3) + + url = reverse("userstory-attachments-bulk-update-order") + + data = { + "object_id": us.id, + "after_attachment_id": att2.id, + "bulk_attachments": [att3.id, + att1.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + att3.id, + att1.id, + ] + res = (us.attachments.filter(id__in=updated_ids) + .values("id", "order") + .order_by("order", "id")) + assert response.json() == list(res) + + att1.refresh_from_db() + att2.refresh_from_db() + att3.refresh_from_db() + assert att2.order == 2 + assert att3.order == 3 + assert att1.order == 4 + + +def test_api_update_orders_in_bulk_invalid_object_id(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us = f.create_userstory(project=project) + + att1 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=1) + att2 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=2) + att3 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=3) + + url = reverse("userstory-attachments-bulk-update-order") + + data = { + "after_attachment_id": att2.id, + "bulk_attachments": [att3.id, + att1.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "object_id" in response.data + + data["object_id"] = None + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "object_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_attachments(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + + att1 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=1) + att2 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=2) + att3 = f.UserStoryAttachmentFactory(project=us2.project, content_object=us2, order=3) + + url = reverse("userstory-attachments-bulk-update-order") + + data = { + "object_id": us.id, + "after_attachment_id": att2.id, + "bulk_attachments": [att3.id, + att1.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "bulk_attachments" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_attachment_because_object(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + + att1 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=1) + att2 = f.UserStoryAttachmentFactory(project=us.project, content_object=us, order=2) + att3 = f.UserStoryAttachmentFactory(project=us2.project, content_object=us2, order=3) + + url = reverse("userstory-attachments-bulk-update-order") + + data = { + "object_id": us.id, + "after_attachment_id": att3.id, + "bulk_attachments": [att2.id, + att1.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "after_attachment_id" in response.data diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 000000000..2104df40a --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,495 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.urls import reverse +from django.core import mail + +from .. import factories + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def register_form(): + return {"username": "username", + "password": "password", + "full_name": "fname", + "email": "user@email.com", + "accepted_terms": True, + "type": "public"} + +################# +# registration +################# + +def test_respond_201_when_public_registration_is_enabled(client, settings, register_form): + settings.PUBLIC_REGISTER_ENABLED = True + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + +def test_respond_400_when_public_registration_is_disabled(client, register_form, settings): + settings.PUBLIC_REGISTER_ENABLED = False + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + +def test_respond_400_when_the_email_domain_isnt_in_allowed_domains(client, register_form, settings): + settings.PUBLIC_REGISTER_ENABLED = True + settings.USER_EMAIL_ALLOWED_DOMAINS = ['other-domain.com'] + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + +def test_respond_201_when_the_email_domain_is_in_allowed_domains(client, settings, register_form): + settings.PUBLIC_REGISTER_ENABLED = True + settings.USER_EMAIL_ALLOWED_DOMAINS = ['email.com'] + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + +def test_response_200_in_public_registration(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + form = { + "type": "public", + "username": "mmcfly", + "full_name": "martin seamus mcfly", + "email": "mmcfly@bttf.com", + "password": "password", + "accepted_terms": True, + } + + response = client.post(reverse("auth-register"), form) + assert response.status_code == 201 + assert response.data["username"] == "mmcfly" + assert response.data["email"] == "mmcfly@bttf.com" + assert response.data["full_name"] == "martin seamus mcfly" + assert len(mail.outbox) == 1 + assert mail.outbox[0].subject == "You've been Taigatized!" + + +def test_respond_400_if_username_is_invalid(client, settings, register_form): + settings.PUBLIC_REGISTER_ENABLED = True + + register_form.update({"username": "User Examp:/e"}) + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + register_form.update({"username": 300*"a"}) + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + +def test_respond_400_if_username_or_email_is_duplicate(client, settings, register_form): + settings.PUBLIC_REGISTER_ENABLED = True + + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + register_form["username"] = "username" + register_form["email"] = "ff@dd.com" + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + +def test_register_success_throttling(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["register-success"] = "1/minute" + + register_form = {"username": "valid_username_register_success", + "password": "valid_password", + "full_name": "fullname", + "email": "", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + register_form = {"username": "valid_username_register_success", + "password": "valid_password", + "full_name": "fullname", + "email": "valid_username_register_success@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + register_form = {"username": "valid_username_register_success2", + "password": "valid_password2", + "full_name": "fullname", + "email": "valid_username_register_success2@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 429 + + register_form = {"username": "valid_username_register_success2", + "password": "valid_password2", + "full_name": "fullname", + "email": "", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 429 + + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["register-success"] = None + + +INVALID_NAMES = [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod", + "an example", + "http://testdomain.com", + "https://testdomain.com", + "Visit http://testdomain.com", +] + +@pytest.mark.parametrize("full_name", INVALID_NAMES) +def test_register_sanitize_invalid_user_full_name(client, settings, full_name, register_form): + settings.PUBLIC_REGISTER_ENABLED = True + register_form["full_name"] = full_name + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + +VALID_NAMES = [ + "martin seamus mcfly" +] + +@pytest.mark.parametrize("full_name", VALID_NAMES) +def test_register_sanitize_valid_user_full_name(client, settings, full_name, register_form): + settings.PUBLIC_REGISTER_ENABLED = True + register_form["full_name"] = full_name + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + +def test_registration_case_insensitive_for_username_and_password(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + + register_form = {"username": "Username", + "password": "password", + "full_name": "fname", + "email": "User@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + # Email is case insensitive in the register process + register_form = {"username": "username2", + "password": "password", + "full_name": "fname", + "email": "user@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + # Username is case insensitive in the register process too + register_form = {"username": "username", + "password": "password", + "full_name": "fname", + "email": "user2@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + +################# +# autehtication +################# + +def test_get_auth_token_with_username(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + + +def test_get_auth_token_with_username_case_insensitive(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": user.username.upper(), + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + + +def test_get_auth_token_with_email(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": user.email, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + + +def test_get_auth_token_with_email_case_insensitive(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": user.email.upper(), + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + + +def test_get_auth_token_with_project_invitation(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + membership = factories.MembershipFactory(user=None) + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + "invitation_token": membership.token, + } + + assert membership.user == None + + response = client.post(reverse("auth-list"), auth_data) + membership.refresh_from_db() + + assert response.status_code == 200, response.data + assert "auth_token" in response.data and response.data["auth_token"] + assert "refresh" in response.data and response.data["refresh"] + assert membership.user == user + + +def test_get_auth_token_error_invalid_credentials(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory() + + auth_data = { + "username": "bad username", + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + + auth_data = { + "username": user.username, + "password": "invalid password", + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + + +def test_get_auth_token_error_inactive_user(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory(is_active=False) + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + + +def test_get_auth_token_error_inactive_user(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory(is_active=False) + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + +def test_get_auth_token_error_system_user(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False + user = factories.UserFactory(is_system=True) + + auth_data = { + "username": user.username, + "password": user.username, + "type": "normal", + } + + response = client.post(reverse("auth-list"), auth_data) + + assert response.status_code == 401, response.data + + +def test_auth_uppercase_ignore(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + + register_form = {"username": "Username", + "password": "password", + "full_name": "fname", + "email": "User@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + #Only exists one user with the same lowercase version of username/password + login_form = {"type": "normal", + "username": "Username", + "password": "password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 200 + + login_form = {"type": "normal", + "username": "User@email.com", + "password": "password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 200 + + # Email is case insensitive in the register process + register_form = {"username": "username2", + "password": "password", + "full_name": "fname", + "email": "user@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + # Username is case insensitive in the register process too + register_form = {"username": "username", + "password": "password", + "full_name": "fname", + "email": "user2@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 + + #Now we create a legacy user so we have two users with the same lowercase version of username/email + legacy_user = factories.UserFactory( + username="username", + full_name="fname", + email="user@email.com") + legacy_user.set_password("password") + + + login_form = {"type": "normal", + "username": "Username", + "password": "password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 200 + + login_form = {"type": "normal", + "username": "User@email.com", + "password": "password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 200 + + # 2.- If we capitalize a new version it doesn't work with username + login_form = {"type": "normal", + "username": "uSername", + "password": "password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 401 + + # neither with the email + login_form = {"type": "normal", + "username": "uSer@email.com", + "password": "password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 401 + + +def test_login_fail_throttling(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["login-fail"] = "1/minute" + + register_form = {"username": "valid_username_login_fail", + "password": "valid_password", + "full_name": "fullname", + "email": "valid_username_login_fail@email.com", + "accepted_terms": True, + "type": "public"} + response = client.post(reverse("auth-register"), register_form) + + login_form = {"type": "normal", + "username": "valid_username_login_fail", + "password": "valid_password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 200, response.data + + login_form = {"type": "normal", + "username": "invalid_username_login_fail", + "password": "invalid_password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 401, response.data + + login_form = {"type": "normal", + "username": "invalid_username_login_fail", + "password": "invalid_password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 429, response.data + + login_form = {"type": "normal", + "username": "valid_username_login_fail", + "password": "valid_password"} + + response = client.post(reverse("auth-list"), login_form) + assert response.status_code == 429, response.data + + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["login-fail"] = None + diff --git a/tests/integration/test_contact.py b/tests/integration/test_contact.py new file mode 100644 index 000000000..b2b003835 --- /dev/null +++ b/tests/integration/test_contact.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.core import mail +from django.urls import reverse + +from tests import factories as f + +from taiga.base.utils import json + +import pytest +pytestmark = pytest.mark.django_db + + +# Members can comment on a private project +# if the project has the contact activated +def test_member_create_comment_on_private_project(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(is_private=True) + project.is_contact_activated = True + m1 = f.MembershipFactory(user=project.owner, project=project) + m2 = f.MembershipFactory(project=project, is_admin=True) + m3 = f.MembershipFactory(user=user, project=project, is_admin=False) + + url = reverse("contact-list") + + contact_data = json.dumps({ + "project": project.id, + "comment": "Testing comment" + }) + + client.login(user) + + assert len(mail.outbox) == 0 + response = client.post(url, contact_data, content_type="application/json") + assert response.status_code == 201 + assert len(mail.outbox) == 2 + assert set([to for out in mail.outbox for to in out.to]) == set([project.owner.email, m2.user.email]) + + +# Non members user cannot comment on a private project +# even if the project has the contact activated +def test_guest_create_comment_on_private_project(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(is_private=True) + project.is_contact_activated = True + + url = reverse("contact-list") + + contact_data = json.dumps({ + "project": project.id, + "comment": "Testing comment" + }) + + client.login(user) + + response = client.post(url, contact_data, content_type="application/json") + assert response.status_code == 403 + assert len(mail.outbox) == 0 + + +# All user can comment on a public project +# if the project has the contact activated +def test_create_comment_on_public_project(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(is_private=False) + project.is_contact_activated = True + m1 = f.MembershipFactory(user=project.owner, project=project) + m2 = f.MembershipFactory(project=project, is_admin=True) + url = reverse("contact-list") + + contact_data = json.dumps({ + "project": project.id, + "comment": "Testing comment" + }) + + client.login(user) + + assert len(mail.outbox) == 0 + response = client.post(url, contact_data, content_type="application/json") + assert response.status_code == 201 + assert len(mail.outbox) == 2 + assert set([to for out in mail.outbox for to in out.to]) == set([project.owner.email, m2.user.email]) + + +# No user can comment on a project +# if the project does not have the contact activated +def test_create_comment_disabled(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + project.is_contact_activated = False + project.save() + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + + url = reverse("contact-list") + + contact_data = json.dumps({ + "project": project.id, + "comment": "Testing comment" + }) + + client.login(user) + + response = client.post(url, contact_data, content_type="application/json") + assert response.status_code == 403 diff --git a/tests/integration/test_custom_attributes_epics.py b/tests/integration/test_custom_attributes_epics.py new file mode 100644 index 000000000..5ec6618b8 --- /dev/null +++ b/tests/integration/test_custom_attributes_epics.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.transaction import atomic +from django.urls import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# Epic Custom Attributes +######################################################### + +def test_epic_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.EpicCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("epic-custom-attributes-list") + data = {"name": custom_attr_1.name, + "project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_epic_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.EpicCustomAttributeFactory() + custom_attr_2 = f.EpicCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("epic-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"name": custom_attr_1.name} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_epic_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.EpicCustomAttributeFactory() + custom_attr_2 = f.EpicCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_admin=True) + + url = reverse("epic-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# Epic Custom Attributes Values +######################################################### + +def test_epic_custom_attributes_values_when_create_us(client): + epic = f.EpicFactory() + assert epic.custom_attributes_values.attributes_values == {} + + +def test_epic_custom_attributes_values_update(client): + epic = f.EpicFactory() + member = f.MembershipFactory(user=epic.project.owner, + project=epic.project, + is_admin=True) + + custom_attr_1 = f.EpicCustomAttributeFactory(project=epic.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=epic.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = epic.custom_attributes_values + + url = reverse("epic-custom-attributes-values-detail", args=[epic.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + + assert epic.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["attributes_values"] == data["attributes_values"] + epic = epic.__class__.objects.get(id=epic.id) + assert epic.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_epic_custom_attributes_values_update_with_error_invalid_key(client): + epic = f.EpicFactory() + member = f.MembershipFactory(user=epic.project.owner, + project=epic.project, + is_admin=True) + + custom_attr_1 = f.EpicCustomAttributeFactory(project=epic.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=epic.project) + + custom_attrs_val = epic.custom_attributes_values + + url = reverse("epic-custom-attributes-values-detail", args=[epic.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + +def test_epic_custom_attributes_values_delete_epic(client): + epic = f.EpicFactory() + member = f.MembershipFactory(user=epic.project.owner, + project=epic.project, + is_admin=True) + + custom_attr_1 = f.EpicCustomAttributeFactory(project=epic.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=epic.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = epic.custom_attributes_values + + url = reverse("epics-detail", args=[epic.id]) + + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + assert not epic.__class__.objects.filter(id=epic.id).exists() + assert not custom_attrs_val.__class__.objects.filter(id=custom_attrs_val.id).exists() + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_epiccustomvalues_afeter_remove_epiccustomattribute(client): + epic = f.EpicFactory() + member = f.MembershipFactory(user=epic.project.owner, + project=epic.project, + is_admin=True) + custom_attr_1 = f.EpicCustomAttributeFactory(project=epic.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=epic.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = epic.custom_attributes_values + custom_attrs_val.attributes_values = {ct1_id: "test_1", ct2_id: "test_2"} + custom_attrs_val.save() + + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id in custom_attrs_val.attributes_values.keys() + + url = reverse("epic-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + + custom_attrs_val = custom_attrs_val.__class__.objects.get(id=custom_attrs_val.id) + assert not custom_attr_2.__class__.objects.filter(pk=custom_attr_2.pk).exists() + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id not in custom_attrs_val.attributes_values.keys() diff --git a/tests/integration/test_custom_attributes_issues.py b/tests/integration/test_custom_attributes_issues.py new file mode 100644 index 000000000..decbc25d1 --- /dev/null +++ b/tests/integration/test_custom_attributes_issues.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.transaction import atomic +from django.urls import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# Issue Custom Attributes +######################################################### + +def test_issue_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.IssueCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("issue-custom-attributes-list") + data = {"name": custom_attr_1.name, + "project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_issue_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.IssueCustomAttributeFactory() + custom_attr_2 = f.IssueCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("issue-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"name": custom_attr_1.name} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_issue_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.IssueCustomAttributeFactory() + custom_attr_2 = f.IssueCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_admin=True) + + + url = reverse("issue-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# Issue Custom Attributes Values +######################################################### + +def test_issue_custom_attributes_values_when_create_us(client): + issue = f.IssueFactory() + assert issue.custom_attributes_values.attributes_values == {} + + +def test_issue_custom_attributes_values_update(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_admin=True) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = issue.custom_attributes_values + + url = reverse("issue-custom-attributes-values-detail", args=[issue.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + + assert issue.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["attributes_values"] == data["attributes_values"] + issue = issue.__class__.objects.get(id=issue.id) + assert issue.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_issue_custom_attributes_values_update_with_error_invalid_key(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_admin=True) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + + custom_attrs_val = issue.custom_attributes_values + + url = reverse("issue-custom-attributes-values-detail", args=[issue.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + +def test_issue_custom_attributes_values_delete_issue(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_admin=True) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = issue.custom_attributes_values + + url = reverse("issues-detail", args=[issue.id]) + + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + assert not issue.__class__.objects.filter(id=issue.id).exists() + assert not custom_attrs_val.__class__.objects.filter(id=custom_attrs_val.id).exists() + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_issuecustomvalues_afeter_remove_issuecustomattribute(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_admin=True) + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = issue.custom_attributes_values + custom_attrs_val.attributes_values = {ct1_id: "test_1", ct2_id: "test_2"} + custom_attrs_val.save() + + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id in custom_attrs_val.attributes_values.keys() + + url = reverse("issue-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + + custom_attrs_val = custom_attrs_val.__class__.objects.get(id=custom_attrs_val.id) + assert not custom_attr_2.__class__.objects.filter(pk=custom_attr_2.pk).exists() + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id not in custom_attrs_val.attributes_values.keys() diff --git a/tests/integration/test_custom_attributes_tasks.py b/tests/integration/test_custom_attributes_tasks.py new file mode 100644 index 000000000..d64899b15 --- /dev/null +++ b/tests/integration/test_custom_attributes_tasks.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# Task Custom Attributes +######################################################### + +def test_task_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.TaskCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("task-custom-attributes-list") + data = {"name": custom_attr_1.name, + "project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_task_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.TaskCustomAttributeFactory() + custom_attr_2 = f.TaskCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("task-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"name": custom_attr_1.name} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_task_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.TaskCustomAttributeFactory() + custom_attr_2 = f.TaskCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_admin=True) + + + url = reverse("task-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# Task Custom Attributes Values +######################################################### + +def test_task_custom_attributes_values_when_create_us(client): + task = f.TaskFactory() + assert task.custom_attributes_values.attributes_values == {} + + +def test_task_custom_attributes_values_update(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_admin=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = task.custom_attributes_values + + url = reverse("task-custom-attributes-values-detail", args=[task.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert task.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["attributes_values"] == data["attributes_values"] + task = task.__class__.objects.get(id=task.id) + assert task.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_task_custom_attributes_values_update_with_error_invalid_key(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_admin=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + + custom_attrs_val = task.custom_attributes_values + + url = reverse("task-custom-attributes-values-detail", args=[task.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert task.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_task_custom_attributes_values_delete_task(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_admin=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = task.custom_attributes_values + + url = reverse("tasks-detail", args=[task.id]) + + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + assert not task.__class__.objects.filter(id=task.id).exists() + assert not custom_attrs_val.__class__.objects.filter(id=custom_attrs_val.id).exists() + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_taskcustomvalues_afeter_remove_taskcustomattribute(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_admin=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = task.custom_attributes_values + + custom_attrs_val.attributes_values = {ct1_id: "test_1", ct2_id: "test_2"} + custom_attrs_val.save() + + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id in custom_attrs_val.attributes_values.keys() + + url = reverse("task-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + + custom_attrs_val = custom_attrs_val.__class__.objects.get(id=custom_attrs_val.id) + assert not custom_attr_2.__class__.objects.filter(pk=custom_attr_2.pk).exists() + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id not in custom_attrs_val.attributes_values.keys() diff --git a/tests/integration/test_custom_attributes_user_stories.py b/tests/integration/test_custom_attributes_user_stories.py new file mode 100644 index 000000000..b40dafa44 --- /dev/null +++ b/tests/integration/test_custom_attributes_user_stories.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# User Story Custom Attributes +######################################################### + +def test_userstory_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.UserStoryCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("userstory-custom-attributes-list") + data = {"name": custom_attr_1.name, + "project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_userstory_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.UserStoryCustomAttributeFactory() + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + + + url = reverse("userstory-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"name": custom_attr_1.name} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_userstory_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.UserStoryCustomAttributeFactory() + custom_attr_2 = f.UserStoryCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_admin=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_admin=True) + + + url = reverse("userstory-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# User Story Custom Attributes Values +######################################################### + +def test_userstory_custom_attributes_values_when_create_us(client): + user_story = f.UserStoryFactory() + assert user_story.custom_attributes_values.attributes_values == {} + + +def test_userstory_custom_attributes_values_update(client): + user_story = f.UserStoryFactory() + member = f.MembershipFactory(user=user_story.project.owner, + project=user_story.project, + is_admin=True) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = user_story.custom_attributes_values + + url = reverse("userstory-custom-attributes-values-detail", args=[user_story.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert user_story.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["attributes_values"] == data["attributes_values"] + user_story = user_story.__class__.objects.get(id=user_story.id) + assert user_story.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_userstory_custom_attributes_values_update_with_error_invalid_key(client): + user_story = f.UserStoryFactory() + member = f.MembershipFactory(user=user_story.project.owner, + project=user_story.project, + is_admin=True) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=user_story.project) + + custom_attrs_val = user_story.custom_attributes_values + + url = reverse("userstory-custom-attributes-values-detail", args=[user_story.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert user_story.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_userstorycustomvalues_afeter_remove_userstorycustomattribute(client): + user_story = f.UserStoryFactory() + member = f.MembershipFactory(user=user_story.project.owner, + project=user_story.project, + is_admin=True) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = user_story.custom_attributes_values + + custom_attrs_val.attributes_values = {ct1_id: "test_1", ct2_id: "test_2"} + custom_attrs_val.save() + + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id in custom_attrs_val.attributes_values.keys() + + url = reverse("userstory-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + + custom_attrs_val = custom_attrs_val.__class__.objects.get(id=custom_attrs_val.id) + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id not in custom_attrs_val.attributes_values.keys() diff --git a/tests/integration/test_emails.py b/tests/integration/test_emails.py new file mode 100644 index 000000000..ed2925af0 --- /dev/null +++ b/tests/integration/test_emails.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.core.management import call_command + +from .. import factories as f + + +@pytest.mark.django_db +def test_emails(): + # Membership invitation + m = f.MembershipFactory.create() + m.user = None + m.save() + + # Regular membership + f.MembershipFactory.create() + + # f.UserFactory.create() + call_command('test_emails', 'none@example.test') diff --git a/tests/integration/test_epics.py b/tests/integration/test_epics.py new file mode 100644 index 000000000..9b96c5ff4 --- /dev/null +++ b/tests/integration/test_epics.py @@ -0,0 +1,331 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid +import csv + +from unittest import mock + +from django.urls import reverse + +from taiga.base.utils import json +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.epics import services +from taiga.projects.epics import models +from taiga.projects.occ import OCCResourceMixin + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +def test_get_invalid_csv(client): + url = reverse("epics-csv") + + project = f.ProjectFactory.create(epics_csv_uuid=uuid.uuid4().hex) + f.EpicFactory.create(project=project, epics_order=1, status=project.default_us_status) + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 404 + + response = client.get("{}?uuid={}".format(url, "not-valid-uuid")) + assert response.status_code == 404 + + +def test_get_valid_csv(client): + url = reverse("epics-csv") + project = f.ProjectFactory.create(epics_csv_uuid=uuid.uuid4().hex) + + response = client.get("{}?uuid={}".format(url, project.epics_csv_uuid)) + assert response.status_code == 200 + + +def test_custom_fields_csv_generation(): + project = f.ProjectFactory.create(epics_csv_uuid=uuid.uuid4().hex) + attr = f.EpicCustomAttributeFactory.create(project=project, name="attr1", description="desc") + epic = f.EpicFactory.create(project=project) + attr_values = epic.custom_attributes_values + attr_values.attributes_values = {str(attr.id): "val1"} + attr_values.save() + queryset = project.epics.all() + data = services.epics_to_csv(project, queryset) + data.seek(0) + reader = csv.reader(data) + row = next(reader) + + assert row[19] == attr.name + row = next(reader) + assert row[19] == "val1" + + +def test_update_epic_order(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + epic_1 = f.EpicFactory.create(project=project, epics_order=1, status=project.default_us_status) + epic_2 = f.EpicFactory.create(project=project, epics_order=2, status=project.default_us_status) + epic_3 = f.EpicFactory.create(project=project, epics_order=3, status=project.default_us_status) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + url = reverse('epics-detail', kwargs={"pk": epic_1.pk}) + data = { + "epics_order": 2, + "version": epic_1.version + } + + client.login(user) + response = client.json.patch(url, json.dumps(data)) + assert json.loads(response.get("taiga-info-order-updated")) == { + str(epic_1.id): 2, + str(epic_2.id): 3, + str(epic_3.id): 4 + } + + +def test_bulk_create_related_userstories(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + epic = f.EpicFactory.create(project=project) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + url = reverse('epics-related-userstories-bulk-create', args=[epic.pk]) + + data = { + "bulk_userstories": "test1\ntest2", + "project_id": project.id + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + assert len(response.data) == 2 + + +def test_bulk_create_related_userstories_with_default_swimlane_and_kanban_enable(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + swimlane = f.SwimlaneFactory.create(project=project) + swimlane2 = f.SwimlaneFactory.create(project=project) + epic = f.EpicFactory.create(project=project) + + project.default_swimlane = swimlane + project.is_kanban_activated = True + project.save() + + url = reverse('epics-related-userstories-bulk-create', args=[epic.pk]) + + data = { + "bulk_userstories": "test1\ntest2", + "project_id": project.id + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + assert len(response.data) == 2 + + userstories = epic.user_stories.all() + assert userstories[0].swimlane == swimlane + assert userstories[1].swimlane == swimlane + + +def test_bulk_create_related_userstories_with_default_swimlane_and_kanban_disable(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + swimlane = f.SwimlaneFactory.create(project=project) + swimlane2 = f.SwimlaneFactory.create(project=project) + epic = f.EpicFactory.create(project=project) + + project.default_swimlane = swimlane + project.is_kanban_activated = False + project.save() + + url = reverse('epics-related-userstories-bulk-create', args=[epic.pk]) + + data = { + "bulk_userstories": "test1\ntest2", + "project_id": project.id + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + assert len(response.data) == 2 + + userstories = epic.user_stories.all() + assert userstories[0].swimlane == None + assert userstories[1].swimlane == None + + +def test_set_related_userstory(client): + user = f.UserFactory.create() + epic = f.EpicFactory.create() + us = f.UserStoryFactory.create() + f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) + f.MembershipFactory.create(project=us.project, user=user, is_admin=True) + + url = reverse('epics-related-userstories-list', args=[epic.pk]) + + data = { + "user_story": us.id, + "epic": epic.pk + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + +def test_set_related_userstory_existing(client): + user = f.UserFactory.create() + epic = f.EpicFactory.create() + us = f.UserStoryFactory.create() + related_us = f.RelatedUserStory.create(epic=epic, user_story=us, order=55) + f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) + f.MembershipFactory.create(project=us.project, user=user, is_admin=True) + + url = reverse('epics-related-userstories-detail', args=[epic.pk, us.pk]) + data = { + "order": 77 + } + client.login(user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + + related_us.refresh_from_db() + assert related_us.order == 77 + + +def test_unset_related_userstory(client): + user = f.UserFactory.create() + epic = f.EpicFactory.create() + us = f.UserStoryFactory.create() + related_us = f.RelatedUserStory.create(epic=epic, user_story=us, order=55) + f.MembershipFactory.create(project=epic.project, user=user, is_admin=True) + + url = reverse('epics-related-userstories-detail', args=[epic.pk, us.id]) + + client.login(user) + response = client.delete(url) + assert response.status_code == 204 + assert not models.RelatedUserStory.objects.filter(id=related_us.id).exists() + + +def test_api_validator_assigned_to_when_update_epics(client): + project = f.create_project(anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + project_member_owner = f.MembershipFactory.create(project=project, + user=project.owner, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_member = f.MembershipFactory.create(project=project, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_no_member = f.MembershipFactory.create(is_admin=True) + + epic = f.create_epic(project=project, owner=project.owner, status=project.epic_statuses.all()[0]) + + url = reverse('epics-detail', kwargs={"pk": epic.pk}) + + # assign + data = { + "assigned_to": project_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == project_member.user.id + + # unassign + data = { + "assigned_to": None, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == None + + # assign to invalid user + data = { + "assigned_to": project_no_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + + +def test_api_validator_assigned_to_when_create_epics(client): + project = f.create_project(anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + project_member_owner = f.MembershipFactory.create(project=project, + user=project.owner, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_member = f.MembershipFactory.create(project=project, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_no_member = f.MembershipFactory.create(is_admin=True) + + url = reverse('epics-list') + + # assign + data = { + "subject": "test", + "project": project.id, + "assigned_to": project_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == project_member.user.id + + # unassign + data = { + "subject": "test", + "project": project.id, + "assigned_to": None, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == None + + # assign to invalid user + data = { + "subject": "test", + "project": project.id, + "assigned_to": project_no_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data diff --git a/tests/integration/test_epics_tags.py b/tests/integration/test_epics_tags.py new file mode 100644 index 000000000..69b610472 --- /dev/null +++ b/tests/integration/test_epics_tags.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from unittest import mock +from collections import OrderedDict + +from django.urls import reverse + +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +def test_api_epic_add_new_tags_with_error(client): + project = f.ProjectFactory.create() + epic = f.create_epic(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=epic.owner, is_admin=True) + url = reverse("epics-detail", kwargs={"pk": epic.pk}) + data = { + "tags": [], + "version": epic.version + } + + client.login(epic.owner) + + data["tags"] = [1] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [["back"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [["back", "#cccc"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [[1, "#ccc"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + +def test_api_epic_add_new_tags_without_colors(client): + project = f.ProjectFactory.create() + epic = f.create_epic(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=epic.owner, is_admin=True) + url = reverse("epics-detail", kwargs={"pk": epic.pk}) + data = { + "tags": [ + ["back", None], + ["front", None], + ["ux", None] + ], + "version": epic.version + } + + client.login(epic.owner) + + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200, response.data + + tags_colors = OrderedDict(project.tags_colors) + assert not tags_colors.keys() + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors + + +def test_api_epic_add_new_tags_with_colors(client): + project = f.ProjectFactory.create() + epic = f.create_epic(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=epic.owner, is_admin=True) + url = reverse("epics-detail", kwargs={"pk": epic.pk}) + data = { + "tags": [ + ["back", "#fff8e7"], + ["front", None], + ["ux", "#fabada"] + ], + "version": epic.version + } + + client.login(epic.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + + tags_colors = OrderedDict(project.tags_colors) + assert not tags_colors.keys() + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors + assert tags_colors["back"] == "#fff8e7" + assert tags_colors["ux"] == "#fabada" + + +def test_api_create_new_epic_with_tags(client): + project = f.ProjectFactory.create(tags_colors=[["front", "#aaaaaa"], ["ux", "#fabada"]]) + status = f.EpicStatusFactory.create(project=project) + project.default_epic_status = status + project.save() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("epics-list") + + data = { + "subject": "Test user story", + "project": project.id, + "tags": [ + ["back", "#fff8e7"], + ["front", None], + ["ux", "#fabada"] + ] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + + epic_tags_colors = OrderedDict(response.data["tags"]) + + assert epic_tags_colors["back"] == "#fff8e7" + assert epic_tags_colors["front"] == "#aaaaaa" + assert epic_tags_colors["ux"] == "#fabada" + + tags_colors = OrderedDict(project.tags_colors) + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert tags_colors["back"] == "#fff8e7" + assert tags_colors["ux"] == "#fabada" + assert tags_colors["front"] == "#aaaaaa" diff --git a/tests/integration/test_exporter_api.py b/tests/integration/test_exporter_api.py new file mode 100644 index 000000000..b986cc013 --- /dev/null +++ b/tests/integration/test_exporter_api.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from unittest import mock + +from django.urls import reverse + +from .. import factories as f +from taiga.base.utils import json + + +pytestmark = pytest.mark.django_db + + +def test_invalid_project_export(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("exporter-detail", args=[1000000]) + + response = client.get(url, content_type="application/json") + assert response.status_code == 404 + + +def test_valid_project_export_with_celery_disabled(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("exporter-detail", args=[project.pk]) + + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + response_data = response.data + assert "url" in response_data + assert response_data["url"].endswith(".json") + + +def test_valid_project_export_with_celery_disabled_and_gzip(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("exporter-detail", args=[project.pk]) + + response = client.get(url+"?dump_format=gzip", content_type="application/json") + assert response.status_code == 200 + response_data = response.data + assert "url" in response_data + assert response_data["url"].endswith(".gz") + + +def test_valid_project_export_with_celery_enabled(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("exporter-detail", args=[project.pk]) + + #delete_project_dump task should have been launched + with mock.patch('taiga.export_import.tasks.delete_project_dump') as delete_project_dump_mock: + response = client.get(url, content_type="application/json") + assert response.status_code == 202 + response_data = response.data + assert "export_id" in response_data + + args = (project.id, project.slug, response_data["export_id"], "plain") + kwargs = {"countdown": settings.EXPORTS_TTL} + delete_project_dump_mock.apply_async.assert_called_once_with(args, **kwargs) + settings.CELERY_ENABLED = False + + +def test_valid_project_export_with_celery_enabled_and_gzip(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("exporter-detail", args=[project.pk]) + + #delete_project_dump task should have been launched + with mock.patch('taiga.export_import.tasks.delete_project_dump') as delete_project_dump_mock: + response = client.get(url+"?dump_format=gzip", content_type="application/json") + assert response.status_code == 202 + response_data = response.data + assert "export_id" in response_data + + args = (project.id, project.slug, response_data["export_id"], "gzip") + kwargs = {"countdown": settings.EXPORTS_TTL} + delete_project_dump_mock.apply_async.assert_called_once_with(args, **kwargs) + settings.CELERY_ENABLED = False + + +def test_valid_project_with_throttling(client, settings): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["import-dump-mode"] = "1/minute" + + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("exporter-detail", args=[project.pk]) + + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + response = client.get(url, content_type="application/json") + assert response.status_code == 429 diff --git a/tests/integration/test_fan_projects.py b/tests/integration/test_fan_projects.py new file mode 100644 index 000000000..ddba5c6e4 --- /dev/null +++ b/tests/integration/test_fan_projects.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from django.urls import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_like_project(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("projects-like", args=(project.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unlike_project(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("projects-unlike", args=(project.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_project_fans(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + f.LikeFactory.create(content_object=project, user=user) + url = reverse("project-fans-list", args=(project.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_project_fan(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + like = f.LikeFactory.create(content_object=project, user=user) + url = reverse("project-fans-detail", args=(project.id, like.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == like.user.id + + +def test_get_project_is_fan(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url_detail = reverse("projects-detail", args=(project.id,)) + url_like = reverse("projects-like", args=(project.id,)) + url_unlike = reverse("projects-unlike", args=(project.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_fans'] == 0 + assert response.data['is_fan'] == False + + response = client.post(url_like) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_fans'] == 1 + assert response.data['is_fan'] == True + + response = client.post(url_unlike) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_fans'] == 0 + assert response.data['is_fan'] == False diff --git a/tests/integration/test_feedback.py b/tests/integration/test_feedback.py new file mode 100644 index 000000000..63d950ab8 --- /dev/null +++ b/tests/integration/test_feedback.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse + +from tests import factories as f + +from taiga.base.utils import json + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def user(): + return f.UserFactory.create() + + +def test_create_feedback(client, user): + url = reverse("feedback-list") + + feedback_data = {"comment": "One feedback comment"} + feedback_data = json.dumps(feedback_data) + + client.login(user) + + response = client.post(url, feedback_data, content_type="application/json") + assert response.status_code == 200 + + assert response.data.get("id", None) + assert response.data.get("created_date", None) + assert response.data.get("full_name", user.full_name) + assert response.data.get("email", user.email) + + client.logout() + + +def test_create_feedback_without_comments(client, user): + url = reverse("feedback-list") + + feedback_data = json.dumps({}) + + client.login(user) + + response = client.post(url, feedback_data, content_type="application/json") + assert response.status_code == 400 + assert response.data.get("comment", None) + + client.logout() diff --git a/tests/integration/test_history.py b/tests/integration/test_history.py new file mode 100644 index 000000000..70d28bcd1 --- /dev/null +++ b/tests/integration/test_history.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from unittest.mock import patch + +from django.urls import reverse +from django.utils import timezone + +from .. import factories as f + +from taiga.base.utils import json +from taiga.projects.history import services +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.choices import HistoryType +from taiga.projects.history.services import make_key_from_model_object + +pytestmark = pytest.mark.django_db + + +def test_take_snapshot_crete(): + issue = f.IssueFactory.create() + + qs_all = HistoryEntry.objects.all() + qs_created = qs_all.filter(type=HistoryType.create) + + assert qs_all.count() == 0 + + services.take_snapshot(issue, user=issue.owner) + + assert qs_all.count() == 1 + assert qs_created.count() == 1 + + +def test_take_two_snapshots_with_changes(): + issue = f.IssueFactory.create() + + qs_all = HistoryEntry.objects.all() + qs_created = qs_all.filter(type=HistoryType.create) + qs_hidden = qs_all.filter(is_hidden=True) + + assert qs_all.count() == 0 + + # Two snapshots with modification should + # generate two snapshots. + services.take_snapshot(issue, user=issue.owner) + issue.description = "foo1" + issue.save() + services.take_snapshot(issue, user=issue.owner) + assert qs_all.count() == 2 + assert qs_created.count() == 1 + assert qs_hidden.count() == 0 + + +def test_take_two_snapshots_without_changes(): + issue = f.IssueFactory.create() + + qs_all = HistoryEntry.objects.all() + qs_created = qs_all.filter(type=HistoryType.create) + qs_hidden = qs_all.filter(is_hidden=True) + + assert qs_all.count() == 0 + + # Two snapshots without modifications only + # generate one unique snapshot. + services.take_snapshot(issue, user=issue.owner) + services.take_snapshot(issue, user=issue.owner) + + assert qs_all.count() == 1 + assert qs_created.count() == 1 + assert qs_hidden.count() == 0 + + +def test_take_snapshot_from_deleted_object(): + issue = f.IssueFactory.create() + + qs_all = HistoryEntry.objects.all() + qs_deleted = qs_all.filter(type=HistoryType.delete) + + assert qs_all.count() == 0 + + services.take_snapshot(issue, user=issue.owner, delete=True) + + assert qs_all.count() == 1 + assert qs_deleted.count() == 1 + + +def test_real_snapshot_frequency(settings): + settings.MAX_PARTIAL_DIFFS = 2 + + issue = f.IssueFactory.create() + counter = 0 + + qs_all = HistoryEntry.objects.all() + qs_snapshots = qs_all.filter(is_snapshot=True) + qs_partials = qs_all.filter(is_snapshot=False) + + assert qs_all.count() == 0 + assert qs_snapshots.count() == 0 + assert qs_partials.count() == 0 + + def _make_change(): + nonlocal counter + issue.description = "desc{}".format(counter) + issue.save() + services.take_snapshot(issue, user=issue.owner) + counter += 1 + + _make_change() + assert qs_all.count() == 1 + assert qs_snapshots.count() == 1 + assert qs_partials.count() == 0 + + _make_change() + assert qs_all.count() == 2 + assert qs_snapshots.count() == 1 + assert qs_partials.count() == 1 + + _make_change() + assert qs_all.count() == 3 + assert qs_snapshots.count() == 1 + assert qs_partials.count() == 2 + + _make_change() + assert qs_all.count() == 4 + assert qs_snapshots.count() == 2 + assert qs_partials.count() == 2 + + +def test_issue_resource_history_test(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project) + f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + issue = f.IssueFactory.create(owner=user, project=project) + + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + url = reverse("issues-detail", args=[issue.pk]) + + client.login(user) + + qs_all = HistoryEntry.objects.all() + qs_deleted = qs_all.filter(type=HistoryType.delete) + qs_changed = qs_all.filter(type=HistoryType.change) + qs_created = qs_all.filter(type=HistoryType.create) + + assert qs_all.count() == 0 + + with patch(mock_path): + data = {"subject": "Fooooo", "version": issue.version} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + assert qs_all.count() == 1 + assert qs_created.count() == 1 + assert qs_changed.count() == 0 + assert qs_deleted.count() == 0 + + with patch(mock_path): + response = client.delete(url) + assert response.status_code == 204 + + assert qs_all.count() == 2 + assert qs_created.count() == 1 + assert qs_changed.count() == 0 + assert qs_deleted.count() == 1 + + +def test_take_hidden_snapshot(): + task = f.TaskFactory.create() + + qs_all = HistoryEntry.objects.all() + qs_hidden = qs_all.filter(is_hidden=True) + + assert qs_all.count() == 0 + + # Two snapshots with modification should + # generate two snapshots. + services.take_snapshot(task, user=task.owner) + task.us_order = 3 + task.save() + + services.take_snapshot(task, user=task.owner) + assert qs_all.count() == 2 + assert qs_hidden.count() == 1 + + +def test_history_with_only_comment_shouldnot_be_hidden(client): + project = f.create_project() + us = f.create_userstory(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + + qs_all = HistoryEntry.objects.all() + qs_hidden = qs_all.filter(is_hidden=True) + + assert qs_all.count() == 0 + + url = reverse("userstories-detail", args=[us.pk]) + data = json.dumps({"comment": "test comment", "version": us.version}) + + client.login(project.owner) + response = client.patch(url, data, content_type="application/json") + + assert response.status_code == 200, str(response.content) + assert qs_all.count() == 1 + assert qs_hidden.count() == 0 + + +def test_delete_comment_by_project_owner(client): + project = f.create_project() + us = f.create_userstory(project=project) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + key = make_key_from_model_object(us) + history_entry = f.HistoryEntryFactory.create(type=HistoryType.change, + project=project, + comment="testing", + key=key, + diff={}, + user={"pk": project.owner.id}) + + client.login(project.owner) + url = reverse("userstory-history-delete-comment", args=(us.id,)) + url = "%s?id=%s" % (url, history_entry.id) + response = client.post(url, content_type="application/json") + assert 200 == response.status_code, response.status_code + + +def test_edit_comment(client): + project = f.create_project() + us = f.create_userstory(project=project) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + key = make_key_from_model_object(us) + history_entry = f.HistoryEntryFactory.create(type=HistoryType.change, + project=project, + comment="testing", + key=key, + diff={}, + user={"pk": project.owner.id}) + + history_entry_created_at = history_entry.created_at + assert history_entry.comment_versions == None + assert history_entry.edit_comment_date == None + + client.login(project.owner) + url = reverse("userstory-history-edit-comment", args=(us.id,)) + url = "%s?id=%s" % (url, history_entry.id) + + data = json.dumps({"comment": "testing update comment"}) + response = client.post(url, data, content_type="application/json") + assert 200 == response.status_code, response.status_code + + + history_entry = HistoryEntry.objects.get(id=history_entry.id) + assert len(history_entry.comment_versions) == 1 + assert history_entry.comment == "testing update comment" + assert history_entry.comment_versions[0]["comment"] == "testing" + assert history_entry.edit_comment_date != None + assert history_entry.comment_versions[0]["user"]["id"] == project.owner.id + + +def test_get_comment_versions(client): + project = f.create_project() + us = f.create_userstory(project=project) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + key = make_key_from_model_object(us) + history_entry = f.HistoryEntryFactory.create( + project=project, + type=HistoryType.change, + comment="testing", + key=key, + diff={}, + user={"pk": project.owner.id}, + edit_comment_date=timezone.now(), + comment_versions = [{ + "comment_html": "

test

", + "date": "2016-05-09T09:34:27.221Z", + "comment": "test", + "user": { + "id": project.owner.id, + }}]) + + client.login(project.owner) + url = reverse("userstory-history-comment-versions", args=(us.id,)) + url = "%s?id=%s" % (url, history_entry.id) + + response = client.get(url, content_type="application/json") + assert 200 == response.status_code, response.status_code + assert response.data[0]["user"]["username"] == project.owner.username + + +def test_get_comment_versions_from_history_entry_without_comment(client): + project = f.create_project() + us = f.create_userstory(project=project) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + key = make_key_from_model_object(us) + history_entry = f.HistoryEntryFactory.create( + project=project, + type=HistoryType.change, + key=key, + diff={}, + user={"pk": project.owner.id}) + + client.login(project.owner) + url = reverse("userstory-history-comment-versions", args=(us.id,)) + url = "%s?id=%s" % (url, history_entry.id) + + response = client.get(url, content_type="application/json") + assert 200 == response.status_code, response.status_code + assert response.data == None diff --git a/tests/integration/test_hooks_bitbucket.py b/tests/integration/test_hooks_bitbucket.py new file mode 100644 index 000000000..c3dd970b4 --- /dev/null +++ b/tests/integration/test_hooks_bitbucket.py @@ -0,0 +1,905 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import urllib + +from unittest import mock + +from django.urls import reverse +from django.core import mail +from django.conf import settings + +from taiga.base.utils import json +from taiga.hooks.bitbucket import event_hooks +from taiga.hooks.bitbucket.api import BitBucketViewSet +from taiga.hooks.exceptions import ActionSyntaxException +from taiga.projects import choices as project_choices +from taiga.projects.epics.models import Epic +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.models import Membership +from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import NotifyPolicy +from taiga.projects import services +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_bad_signature(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "badbadbad") + data = "{}" + response = client.post(url, data, content_type="application/json", HTTP_X_EVENT_KEY="repo:push") + + response_content = response.data + assert response.status_code == 400 + assert "Bad signature" in response_content["_error_message"] + + +def test_ok_signature(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + response = client.post(url, + data, + content_type="application/json", + HTTP_X_EVENT_KEY="repo:push", + REMOTE_ADDR="13.52.5.96") + + assert response.status_code == 204 + + +def test_ok_signature_ip_in_network(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + response = client.post(url, + data, + content_type="application/json", + HTTP_X_EVENT_KEY="repo:push", + REMOTE_ADDR="13.52.5.96") + assert response.status_code == 204 + + +def test_ok_signature_invalid_network(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["131.103.20.160/27;165.254.145.0/26;104.192.143.0/24"], + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + response = client.post(url, + data, + content_type="application/json", + HTTP_X_EVENT_KEY="repo:push", + REMOTE_ADDR="104.192.143.193") + + assert response.status_code == 400 + assert "Bad signature" in response.data["_error_message"] + + +def test_blocked_project(client): + project = f.ProjectFactory(blocked_code=project_choices.BLOCKED_BY_STAFF) + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + response = client.post(url, + data, + content_type="application/json", + HTTP_X_EVENT_KEY="repo:push", + REMOTE_ADDR="13.52.5.96") + + assert response.status_code == 451 + + +def test_invalid_ip(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + response = client.post(url, + data, + content_type="application/json", + HTTP_X_EVENT_KEY="repo:push", + REMOTE_ADDR="111.111.111.112") + assert response.status_code == 400 + + +def test_invalid_origin_ip_settings(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["testing"] + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + response = client.post(url, + data, + content_type="application/json", + HTTP_X_EVENT_KEY="repo:push", + REMOTE_ADDR="111.111.111.112") + assert response.status_code == 400 + + +def test_valid_local_network_ip(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["192.168.1.1"] + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + response = client.post(url, + data, + content_type="application/json", + HTTP_X_EVENT_KEY="repo:push", + REMOTE_ADDR="192.168.1.1") + assert response.status_code == 204 + + +def test_not_ip_filter(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": [] + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + response = client.post(url, + data, + content_type="application/json", + HTTP_X_EVENT_KEY="repo:push", + REMOTE_ADDR="111.111.111.112") + assert response.status_code == 204 + + +def test_push_event_detected(client): + project = f.ProjectFactory() + url = reverse("bitbucket-hook-list") + url = "%s?project=%s" % (url, project.id) + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + + BitBucketViewSet._validate_signature = mock.Mock(return_value=True) + + with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock: + response = client.post(url, data, + HTTP_X_EVENT_KEY="repo:push", + content_type="application/json") + + assert process_event_mock.call_count == 1 + + assert response.status_code == 204 + + +def test_push_event_epic_processing(client): + creation_status = f.EpicStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_epics"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.EpicStatusFactory(project=creation_status.project) + epic = f.EpicFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s #%s ok bye!" % (epic.ref, new_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(epic.project, payload) + ev_hook.process_event() + epic = Epic.objects.get(id=epic.id) + assert epic.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_issue_processing(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s #%s ok bye!" % (issue.ref, new_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue = Issue.objects.get(id=issue.id) + assert issue.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_processing(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s #%s ok bye!" % (task.ref, new_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_processing(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.UserStoryStatusFactory(project=creation_status.project) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s #%s ok bye!" % (user_story.ref, new_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + user_story = UserStory.objects.get(id=user_story.id) + assert user_story.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_issue_mention(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(issue, user=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s ok bye!" % (issue.ref) } + ] + } + ] + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue_history = get_history_queryset_by_model_instance(issue) + assert issue_history.count() == 1 + assert issue_history[0].comment.startswith("This issue has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_task_mention(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(task, user=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s ok bye!" % (task.ref) } + ] + } + ] + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task_history = get_history_queryset_by_model_instance(task) + assert task_history.count() == 1 + assert task_history[0].comment.startswith("This task has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_mention(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(user_story, user=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s ok bye!" % (user_story.ref) } + ] + } + ] + } + } + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + us_history = get_history_queryset_by_model_instance(user_story) + assert us_history.count() == 1 + assert us_history[0].comment.startswith("This user story has been mentioned by") + assert len(mail.outbox) == 1 + + + + +def test_push_event_multiple_actions(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue1 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + issue2 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s #%s ok test TG-%s #%s ok bye!" % (issue1.ref, new_status.slug, issue2.ref, new_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + ev_hook1 = event_hooks.PushEventHook(issue1.project, payload) + ev_hook1.process_event() + issue1 = Issue.objects.get(id=issue1.id) + issue2 = Issue.objects.get(id=issue2.id) + assert issue1.status.id == new_status.id + assert issue2.status.id == new_status.id + assert len(mail.outbox) == 2 + + +def test_push_event_processing_case_insensitive(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s #%s ok bye!" % (task.ref, new_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_bad_processing_non_existing_ref(client): + issue_status = f.IssueStatusFactory() + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-6666666 #%s ok bye!" % (issue_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue_status.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The referenced element doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_task_bad_processing_non_existing_ref(client): + issue_status = f.IssueStatusFactory() + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-6666666 #%s ok bye!" % (issue_status.slug) } + ] + } + ] + } + } + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue_status.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The referenced element doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_us_bad_processing_non_existing_status(client): + user_story = f.UserStoryFactory.create() + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s #non-existing-slug ok bye!" % (user_story.ref) } + ] + } + ] + } + } + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_bad_processing_non_existing_status(client): + issue = f.IssueFactory.create() + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "push": { + "changes": [ + { + "commits": [ + { "message": "test message test TG-%s #non-existing-slug ok bye!" % (issue.ref) } + ] + } + ] + } + } + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_issues_event_opened_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "issue": { + "id": "10", + "title": "test-title", + "links": {"html": {"href": "http://bitbucket.com/site/master/issue/10"}}, + "content": {"raw": "test-content"} + }, + "repository": { + "links": {"html": {"href": "http://bitbucket.com/test-user/test-project"}} + } + } + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 2 + assert len(mail.outbox) == 1 + + +def test_issues_event_bad_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + payload = { + "actor": { + }, + "issue": { + }, + "repository": { + } + } + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "Invalid issue information" + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + +def test_issue_comment_event_on_existing_issue_task_and_us(client): + project = f.ProjectFactory() + role = f.RoleFactory(project=project, permissions=["view_tasks", "view_issues", "view_us"]) + f.MembershipFactory(project=project, role=role, user=project.owner) + user = f.UserFactory() + + issue = f.IssueFactory.create(external_reference=["bitbucket", "http://bitbucket.com/site/master/issue/11"], owner=project.owner, project=project) + take_snapshot(issue, user=user) + task = f.TaskFactory.create(external_reference=["bitbucket", "http://bitbucket.com/site/master/issue/11"], owner=project.owner, project=project) + take_snapshot(task, user=user) + us = f.UserStoryFactory.create(external_reference=["bitbucket", "http://bitbucket.com/site/master/issue/11"], owner=project.owner, project=project) + take_snapshot(us, user=user) + + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "issue": { + "id": "11", + "title": "test-title", + "links": {"html": {"href": "http://bitbucket.com/site/master/issue/11"}}, + "content": {"raw": "test-content"} + }, + "comment": { + "content": {"raw": "Test body"}, + }, + "repository": { + "links": {"html": {"href": "http://bitbucket.com/test-user/test-project"}} + } + } + + mail.outbox = [] + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + ev_hook.process_event() + + issue_history = get_history_queryset_by_model_instance(issue) + assert issue_history.count() == 1 + assert "Test body" in issue_history[0].comment + + task_history = get_history_queryset_by_model_instance(task) + assert task_history.count() == 1 + assert "Test body" in issue_history[0].comment + + us_history = get_history_queryset_by_model_instance(us) + assert us_history.count() == 1 + assert "Test body" in issue_history[0].comment + + assert len(mail.outbox) == 3 + + +def test_issue_comment_event_on_not_existing_issue_task_and_us(client): + issue = f.IssueFactory.create(external_reference=["bitbucket", "10"]) + take_snapshot(issue, user=issue.owner) + task = f.TaskFactory.create(project=issue.project, external_reference=["bitbucket", "10"]) + take_snapshot(task, user=task.owner) + us = f.UserStoryFactory.create(project=issue.project, external_reference=["bitbucket", "10"]) + take_snapshot(us, user=us.owner) + + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "issue": { + "id": "10", + "title": "test-title", + "links": {"html": {"href": "http://bitbucket.com/site/master/issue/10"}}, + "content": {"raw": "test-content"} + }, + "comment": { + "content": {"raw": "Test body"}, + }, + "repository": { + "links": {"html": {"href": "http://bitbucket.com/test-user/test-project"}} + } + } + + mail.outbox = [] + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + ev_hook.process_event() + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + assert len(mail.outbox) == 0 + + +def test_issues_event_bad_comment(client): + issue = f.IssueFactory.create(external_reference=["bitbucket", "10"]) + take_snapshot(issue, user=issue.owner) + + payload = { + "actor": { + "user": { + "uuid": "{ce1054cd-3f43-49dc-8aea-d3085ee7ec9b}", + "username": "test-user", + "links": {"html": {"href": "http://bitbucket.com/test-user"}} + } + }, + "issue": { + "id": "10", + "title": "test-title", + "links": {"html": {"href": "http://bitbucket.com/site/master/issue/10"}}, + "content": {"raw": "test-content"} + }, + "comment": { + }, + "repository": { + "links": {"html": {"href": "http://bitbucket.com/test-user/test-project"}} + } + } + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + + mail.outbox = [] + + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "Invalid issue comment information" + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + +def test_api_get_project_modules(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 200 + content = response.data + assert "bitbucket" in content + assert content["bitbucket"]["secret"] != "" + assert content["bitbucket"]["webhooks_url"] != "" + + +def test_api_patch_project_modules(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + data = { + "bitbucket": { + "secret": "test_secret", + "url": "test_url", + } + } + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 204 + + config = services.get_modules_config(project).config + assert "bitbucket" in config + assert config["bitbucket"]["secret"] == "test_secret" + assert config["bitbucket"]["webhooks_url"] != "test_url" + + +def test_replace_bitbucket_references(): + ev_hook = event_hooks.BaseBitBucketEventHook + assert ev_hook.replace_bitbucket_references(None, "project-url", "#2") == "[BitBucket#2](project-url/issues/2)" + assert ev_hook.replace_bitbucket_references(None, "project-url", "#2 ") == "[BitBucket#2](project-url/issues/2) " + assert ev_hook.replace_bitbucket_references(None, "project-url", " #2 ") == " [BitBucket#2](project-url/issues/2) " + assert ev_hook.replace_bitbucket_references(None, "project-url", " #2") == " [BitBucket#2](project-url/issues/2)" + assert ev_hook.replace_bitbucket_references(None, "project-url", "#test") == "#test" + assert ev_hook.replace_bitbucket_references(None, "project-url", None) == "" diff --git a/tests/integration/test_hooks_github.py b/tests/integration/test_hooks_github.py new file mode 100644 index 000000000..1c43ee1b8 --- /dev/null +++ b/tests/integration/test_hooks_github.py @@ -0,0 +1,675 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from unittest import mock + +from django.urls import reverse +from django.core import mail + +from taiga.base.utils import json +from taiga.hooks.github import event_hooks +from taiga.hooks.github.api import GitHubViewSet +from taiga.hooks.exceptions import ActionSyntaxException +from taiga.projects import choices as project_choices +from taiga.projects.epics.models import Epic +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.models import Membership +from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import NotifyPolicy +from taiga.projects import services +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_bad_project(client): + project = f.ProjectFactory() + url = reverse("github-hook-list") + url = "%s?project=%s-extra-text-added" % (url, project.id) + data = {"test:": "data"} + response = client.post(url, json.dumps(data), + HTTP_X_HUB_SIGNATURE="sha1=3c8e83fdaa266f81c036ea0b71e98eb5e054581a", + content_type="application/json") + response_content = response.data + assert response.status_code == 400 + assert "The project doesn't exist" in response_content["_error_message"] + + +def test_bad_signature(client): + project = f.ProjectFactory() + url = reverse("github-hook-list") + url = "%s?project=%s" % (url, project.id) + data = {} + response = client.post(url, json.dumps(data), + HTTP_X_HUB_SIGNATURE="sha1=badbadbad", + content_type="application/json") + response_content = response.data + assert response.status_code == 400 + assert "Bad signature" in response_content["_error_message"] + + +def test_ok_signature(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "github": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("github-hook-list") + url = "%s?project=%s" % (url, project.id) + data = {"test:": "data"} + response = client.post(url, json.dumps(data), + HTTP_X_HUB_SIGNATURE="sha1=3c8e83fdaa266f81c036ea0b71e98eb5e054581a", + content_type="application/json") + + assert response.status_code == 204 + + +def test_blocked_project(client): + project = f.ProjectFactory(blocked_code=project_choices.BLOCKED_BY_STAFF) + f.ProjectModulesConfigFactory(project=project, config={ + "github": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("github-hook-list") + url = "%s?project=%s" % (url, project.id) + data = {"test:": "data"} + response = client.post(url, json.dumps(data), + HTTP_X_HUB_SIGNATURE="sha1=3c8e83fdaa266f81c036ea0b71e98eb5e054581a", + content_type="application/json") + + assert response.status_code == 451 + + +def test_push_event_detected(client): + project = f.ProjectFactory() + url = reverse("github-hook-list") + url = "%s?project=%s" % (url, project.id) + data = {"commits": [ + {"message": "test message"}, + ]} + + GitHubViewSet._validate_signature = mock.Mock(return_value=True) + + with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock: + response = client.post(url, json.dumps(data), + HTTP_X_GITHUB_EVENT="push", + content_type="application/json") + + assert process_event_mock.call_count == 1 + + assert response.status_code == 204 + + +def test_push_event_epic_processing(client): + creation_status = f.EpicStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_epics"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.EpicStatusFactory(project=creation_status.project) + epic = f.EpicFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """ % (epic.ref, new_status.slug)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(epic.project, payload) + ev_hook.process_event() + epic = Epic.objects.get(id=epic.id) + assert epic.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_issue_processing(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """ % (issue.ref, new_status.slug)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue = Issue.objects.get(id=issue.id) + assert issue.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_processing(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """ % (task.ref, new_status.slug)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_processing(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.UserStoryStatusFactory(project=creation_status.project) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """ % (user_story.ref, new_status.slug)}, + ]} + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + user_story = UserStory.objects.get(id=user_story.id) + assert user_story.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_issue_mention(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(issue, user=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s ok + bye! + """ % (issue.ref)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue_history = get_history_queryset_by_model_instance(issue) + assert issue_history.count() == 1 + assert issue_history[0].comment.startswith("This issue has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_task_mention(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(task, user=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s ok + bye! + """ % (task.ref)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task_history = get_history_queryset_by_model_instance(task) + assert task_history.count() == 1 + assert task_history[0].comment.startswith("This task has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_mention(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(user_story, user=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s ok + bye! + """ % (user_story.ref)}, + ]} + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + us_history = get_history_queryset_by_model_instance(user_story) + assert us_history.count() == 1 + assert us_history[0].comment.startswith("This user story has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_multiple_actions(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue1 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + issue2 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + test TG-%s #%s ok + bye! + """ % (issue1.ref, new_status.slug, issue2.ref, new_status.slug)}, + ]} + mail.outbox = [] + ev_hook1 = event_hooks.PushEventHook(issue1.project, payload) + ev_hook1.process_event() + issue1 = Issue.objects.get(id=issue1.id) + issue2 = Issue.objects.get(id=issue2.id) + assert issue1.status.id == new_status.id + assert issue2.status.id == new_status.id + assert len(mail.outbox) == 2 + + +def test_push_event_processing_case_insensitive(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test tg-%s #%s ok + bye! + """ % (task.ref, new_status.slug.upper())}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_bad_processing_non_existing_ref(client): + issue_status = f.IssueStatusFactory() + payload = {"commits": [ + {"message": """test message + test TG-6666666 #%s ok + bye! + """ % (issue_status.slug)}, + ]} + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue_status.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The referenced element doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_us_bad_processing_non_existing_status(client): + user_story = f.UserStoryFactory.create() + payload = {"commits": [ + {"message": """test message + test TG-%s #non-existing-slug ok + bye! + """ % (user_story.ref)}, + ]} + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_bad_processing_non_existing_status(client): + issue = f.IssueFactory.create() + payload = {"commits": [ + {"message": """test message + test TG-%s #non-existing-slug ok + bye! + """ % (issue.ref)}, + ]} + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_issues_event_opened_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + payload = { + "action": "opened", + "issue": { + "title": "test-title", + "body": "test-body", + "html_url": "http://github.com/test/project/issues/11", + }, + "assignee": {}, + "label": {}, + "repository": { + "html_url": "test", + }, + } + + mail.outbox = [] + + assert Issue.objects.count() == 1 + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 2 + assert len(mail.outbox) == 1 + + +def test_issues_event_edited_issue(client): + issue = f.IssueFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"]) + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + payload = { + "action": "edited", + "issue": { + "title": "test-title", + "body": "test-body updated", + "html_url": "http://github.com/test/project/issues/11", + }, + "assignee": {}, + "label": {}, + } + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + issue.refresh_from_db() + + assert issue.description == payload["issue"]["body"] + + +def test_issues_event_closed_issue(client): + issue = f.IssueFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"]) + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + close_status = f.IssueStatusFactory(project=issue.project, is_closed=True) + f.ProjectModulesConfigFactory(project=issue.project, config={ + "github": {} + }) + + payload = { + "action": "closed", + "issue": { + "title": "test-title", + "body": "test-body", + "html_url": "http://github.com/test/project/issues/11", + }, + "assignee": {}, + "label": {}, + } + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert issue.status == issue.project.default_issue_status + + issue.refresh_from_db() + + assert issue.status == close_status + + +def test_issues_event_reopened_issue(client): + issue = f.IssueFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"]) + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + close_status = f.IssueStatusFactory(project=issue.project, is_closed=True) + issue.status = close_status + issue.save() + + payload = { + "action": "reopened", + "issue": { + "title": "test-title", + "body": "test-body", + "html_url": "http://github.com/test/project/issues/11", + }, + "assignee": {}, + "label": {}, + } + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert issue.status == close_status + + issue.refresh_from_db() + + assert issue.status == issue.project.default_issue_status + + +def test_issues_event_bad_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + payload = { + "action": "opened", + "issue": {}, + "assignee": {}, + "label": {}, + } + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "Invalid issue information" + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + +def test_issue_comment_event_on_existing_issue_task_and_us(client): + project = f.ProjectFactory() + role = f.RoleFactory(project=project, permissions=["view_tasks", "view_issues", "view_us"]) + f.MembershipFactory(project=project, role=role, user=project.owner) + user = f.UserFactory() + + issue = f.IssueFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"], owner=project.owner, project=project) + take_snapshot(issue, user=user) + task = f.TaskFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"], owner=project.owner, project=project) + take_snapshot(task, user=user) + us = f.UserStoryFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"], owner=project.owner, project=project) + take_snapshot(us, user=user) + + payload = { + "action": "created", + "issue": { + "html_url": "http://github.com/test/project/issues/11", + }, + "comment": { + "body": "Test body", + }, + "repository": { + "html_url": "test", + }, + } + + mail.outbox = [] + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + ev_hook.process_event() + + issue_history = get_history_queryset_by_model_instance(issue) + assert issue_history.count() == 1 + assert "Test body" in issue_history[0].comment + + task_history = get_history_queryset_by_model_instance(task) + assert task_history.count() == 1 + assert "Test body" in issue_history[0].comment + + us_history = get_history_queryset_by_model_instance(us) + assert us_history.count() == 1 + assert "Test body" in issue_history[0].comment + + assert len(mail.outbox) == 3 + + +def test_issue_comment_event_on_not_existing_issue_task_and_us(client): + issue = f.IssueFactory.create(external_reference=["github", "10"]) + take_snapshot(issue, user=issue.owner) + task = f.TaskFactory.create(project=issue.project, external_reference=["github", "10"]) + take_snapshot(task, user=task.owner) + us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "10"]) + take_snapshot(us, user=us.owner) + + payload = { + "action": "created", + "issue": { + "html_url": "http://github.com/test/project/issues/11", + }, + "comment": { + "body": "Test body", + }, + "repository": { + "html_url": "test", + }, + } + + mail.outbox = [] + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + ev_hook.process_event() + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + assert len(mail.outbox) == 0 + + +def test_issues_event_bad_comment(client): + issue = f.IssueFactory.create(external_reference=["github", "10"]) + take_snapshot(issue, user=issue.owner) + + payload = { + "action": "created", + "issue": {}, + "comment": {}, + "repository": { + "html_url": "test", + }, + } + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + + mail.outbox = [] + + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "Invalid issue comment information" + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + +def test_api_get_project_modules(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 200 + content = response.data + assert "github" in content + assert content["github"]["secret"] != "" + assert content["github"]["webhooks_url"] != "" + + +def test_api_patch_project_modules(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + data = { + "github": { + "secret": "test_secret", + "url": "test_url", + } + } + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 204 + + config = services.get_modules_config(project).config + assert "github" in config + assert config["github"]["secret"] == "test_secret" + assert config["github"]["webhooks_url"] != "test_url" + + +def test_replace_github_references(): + ev_hook = event_hooks.BaseGitHubEventHook + assert ev_hook.replace_github_references(None, "project-url", "#2") == "[GitHub#2](project-url/issues/2)" + assert ev_hook.replace_github_references(None, "project-url", "#2 ") == "[GitHub#2](project-url/issues/2) " + assert ev_hook.replace_github_references(None, "project-url", " #2 ") == " [GitHub#2](project-url/issues/2) " + assert ev_hook.replace_github_references(None, "project-url", " #2") == " [GitHub#2](project-url/issues/2)" + assert ev_hook.replace_github_references(None, "project-url", "#test") == "#test" + assert ev_hook.replace_github_references(None, "project-url", None) == "" diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py new file mode 100644 index 000000000..e3b212142 --- /dev/null +++ b/tests/integration/test_hooks_gitlab.py @@ -0,0 +1,1471 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from copy import deepcopy + +from unittest import mock + +from django.urls import reverse +from django.core import mail + +from taiga.base.utils import json +from taiga.hooks.gitlab import event_hooks +from taiga.hooks.gitlab.api import GitLabViewSet +from taiga.hooks.exceptions import ActionSyntaxException +from taiga.projects import choices as project_choices +from taiga.projects.epics.models import Epic +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.models import Membership +from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import NotifyPolicy +from taiga.projects import services +from .. import factories as f + +pytestmark = pytest.mark.django_db + +push_base_payload = { + "object_kind": "push", + "before": "95790bf891e76fee5e1747ab589903a6a1f80f22", + "after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "ref": "refs/heads/master", + "checkout_sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "user_id": 4, + "user_name": "John Smith", + "user_email": "john@example.com", + "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", + "project_id": 15, + "project": { + "name": "Diaspora", + "description": "", + "web_url": "http://example.com/mike/diaspora", + "avatar_url": None, + "git_ssh_url": "git@example.com:mike/diaspora.git", + "git_http_url": "http://example.com/mike/diaspora.git", + "namespace": "Mike", + "visibility_level": 0, + "path_with_namespace": "mike/diaspora", + "default_branch": "master", + "homepage": "http://example.com/mike/diaspora", + "url": "git@example.com:mike/diaspora.git", + "ssh_url": "git@example.com:mike/diaspora.git", + "http_url": "http://example.com/mike/diaspora.git" + }, + "repository": { + "name": "Diaspora", + "url": "git@example.com:mike/diaspora.git", + "description": "", + "homepage": "http://example.com/mike/diaspora", + "git_http_url": "http://example.com/mike/diaspora.git", + "git_ssh_url": "git@example.com:mike/diaspora.git", + "visibility_level": 0 + }, + "commits": [ + { + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "message": "Update Catalan translation to e38cb41.", + "timestamp": "2011-12-12T14:27:31+02:00", + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "author": { + "name": "Jordi Mallach", + "email": "jordi@softcatala.org" + }, + "added": ["CHANGELOG"], + "modified": ["app/controller/application.rb"], + "removed": [] + }, + { + "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "message": "fixed readme", + "timestamp": "2012-01-03T23:36:29+02:00", + "url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "author": { + "name": "GitLab dev user", + "email": "gitlabdev@dv6700.(none)" + }, + "added": ["CHANGELOG"], + "modified": ["app/controller/application.rb"], + "removed": [] + } + ], + "total_commits_count": 4 +} + +new_issue_base_payload = { + "object_kind": "issue", + "user": { + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "project": { + "name": "Gitlab Test", + "description": "Aut reprehenderit ut est.", + "web_url": "http://example.com/gitlabhq/gitlab-test", + "avatar_url": None, + "git_ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "git_http_url": "http://example.com/gitlabhq/gitlab-test.git", + "namespace": "GitlabHQ", + "visibility_level": 20, + "path_with_namespace": "gitlabhq/gitlab-test", + "default_branch": "master", + "homepage": "http://example.com/gitlabhq/gitlab-test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "http_url": "http://example.com/gitlabhq/gitlab-test.git" + }, + "repository": { + "name": "Gitlab Test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "description": "Aut reprehenderit ut est.", + "homepage": "http://example.com/gitlabhq/gitlab-test" + }, + "object_attributes": { + "id": 301, + "title": "New API: create/update/delete file", + "assignee_id": 51, + "author_id": 51, + "project_id": 14, + "created_at": "2013-12-03T17:15:43Z", + "updated_at": "2013-12-03T17:15:43Z", + "position": 0, + "branch_name": None, + "description": "Create new API for manipulations with repository", + "milestone_id": None, + "state": "opened", + "iid": 23, + "url": "http://example.com/diaspora/issues/23", + "action": "open" + }, + "assignee": { + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + } +} + +edit_issue_base_payload = { + "object_kind": "issue", + "event_type": "issue", + "user": { + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "project": { + "name": "Gitlab Test", + "description": "Aut reprehenderit ut est.", + "web_url": "http://example.com/gitlabhq/gitlab-test", + "avatar_url": None, + "git_ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "git_http_url": "http://example.com/gitlabhq/gitlab-test.git", + "namespace": "GitlabHQ", + "visibility_level": 20, + "path_with_namespace": "gitlabhq/gitlab-test", + "default_branch": "master", + "homepage": "http://example.com/gitlabhq/gitlab-test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "http_url": "http://example.com/gitlabhq/gitlab-test.git" + }, + "repository": { + "name": "Gitlab Test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "description": "Aut reprehenderit ut est.", + "homepage": "http://example.com/gitlabhq/gitlab-test" + }, + "object_attributes": { + "id": 301, + "title": "New API: create/update/delete file", + "assignee_id": 51, + "author_id": 51, + "project_id": 14, + "created_at": "2013-12-03T17:15:43Z", + "updated_at": "2020-10-01 14:17:48 UTC", + "last_edited_at": "2020-10-01 14:17:48 UTC", + "position": 0, + "branch_name": None, + "description": "Create new API for manipulations with repository", + "milestone_id": None, + "state": "opened", + "iid": 23, + "url": "http://example.com/diaspora/issues/23", + "action": "update", + }, + "labels": [], + "assignee": { + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "changes": { + "last_edited_at": { + "previous": "2013-12-03T17:16:43Z", + "current": "2020-10-01 14:17:48 UTC" + }, + "title": { + "previous": "New API: create file", + "current": "New API: create/update/delete file", + }, + "description": { + "previous": "Create new API for repository", + "current": "Create new API for manipulations with repository", + }, + "updated_at": { + "previous": "2013-12-03T17:16:43Z", + "current": "2020-10-01 14:17:48 UTC" + } + }, +} + +close_issue_base_payload = { + "object_kind": "issue", + "event_type": "issue", + "user": { + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "project": { + "name": "Gitlab Test", + "description": "Aut reprehenderit ut est.", + "web_url": "http://example.com/gitlabhq/gitlab-test", + "avatar_url": None, + "git_ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "git_http_url": "http://example.com/gitlabhq/gitlab-test.git", + "namespace": "GitlabHQ", + "visibility_level": 20, + "path_with_namespace": "gitlabhq/gitlab-test", + "default_branch": "master", + "homepage": "http://example.com/gitlabhq/gitlab-test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "http_url": "http://example.com/gitlabhq/gitlab-test.git" + }, + "repository": { + "name": "Gitlab Test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "description": "Aut reprehenderit ut est.", + "homepage": "http://example.com/gitlabhq/gitlab-test" + }, + "object_attributes": { + "id": 301, + "title": "New API: create/update/delete file", + "assignee_id": 51, + "author_id": 51, + "project_id": 14, + "created_at": "2013-12-03T17:15:43Z", + "updated_at": "2020-10-01 14:17:48 UTC", + "last_edited_at": "2020-10-01 14:17:48 UTC", + "position": 0, + "branch_name": None, + "description": "Create new API for manipulations with repository", + "milestone_id": None, + "state": "closed", + "iid": 23, + "url": "http://example.com/diaspora/issues/23", + "action": "close", + }, + "labels": [], + "assignee": { + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "changes": { + "updated_at": { + "previous": "2013-12-03T17:16:43Z", + "current": "2020-10-01 14:17:48 UTC" + } + }, +} + +reopen_issue_base_payload = { + "object_kind": "issue", + "event_type": "issue", + "user": { + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "project": { + "name": "Gitlab Test", + "description": "Aut reprehenderit ut est.", + "web_url": "http://example.com/gitlabhq/gitlab-test", + "avatar_url": None, + "git_ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "git_http_url": "http://example.com/gitlabhq/gitlab-test.git", + "namespace": "GitlabHQ", + "visibility_level": 20, + "path_with_namespace": "gitlabhq/gitlab-test", + "default_branch": "master", + "homepage": "http://example.com/gitlabhq/gitlab-test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "http_url": "http://example.com/gitlabhq/gitlab-test.git" + }, + "repository": { + "name": "Gitlab Test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "description": "Aut reprehenderit ut est.", + "homepage": "http://example.com/gitlabhq/gitlab-test" + }, + "object_attributes": { + "id": 301, + "title": "New API: create/update/delete file", + "assignee_id": 51, + "author_id": 51, + "project_id": 14, + "created_at": "2013-12-03T17:15:43Z", + "updated_at": "2020-10-01 14:17:48 UTC", + "last_edited_at": "2020-10-01 14:17:48 UTC", + "position": 0, + "branch_name": None, + "description": "Create new API for manipulations with repository", + "milestone_id": None, + "state": "opened", + "iid": 23, + "url": "http://example.com/diaspora/issues/23", + "action": "open", + }, + "labels": [], + "assignee": { + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "changes": { + "updated_at": { + "previous": "2013-12-03T17:16:43Z", + "current": "2020-10-01 14:17:48 UTC" + } + }, +} + +issue_comment_base_payload = { + "object_kind": "note", + "user": { + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }, + "project_id": 5, + "project": { + "name": "Gitlab Test", + "description": "Aut reprehenderit ut est.", + "web_url": "http://example.com/gitlab-org/gitlab-test", + "avatar_url": None, + "git_ssh_url": "git@example.com:gitlab-org/gitlab-test.git", + "git_http_url": "http://example.com/gitlab-org/gitlab-test.git", + "namespace": "Gitlab Org", + "visibility_level": 10, + "path_with_namespace": "gitlab-org/gitlab-test", + "default_branch": "master", + "homepage": "http://example.com/gitlab-org/gitlab-test", + "url": "http://example.com/gitlab-org/gitlab-test.git", + "ssh_url": "git@example.com:gitlab-org/gitlab-test.git", + "http_url": "http://example.com/gitlab-org/gitlab-test.git" + }, + "repository": { + "name": "diaspora", + "url": "git@example.com:mike/diaspora.git", + "description": "", + "homepage": "http://example.com/mike/diaspora" + }, + "object_attributes": { + "id": 1241, + "note": "Hello world", + "noteable_type": "Issue", + "author_id": 1, + "created_at": "2015-05-17 17:06:40 UTC", + "updated_at": "2015-05-17 17:06:40 UTC", + "project_id": 5, + "attachment": None, + "line_code": None, + "commit_id": "", + "noteable_id": 92, + "system": False, + "st_diff": None, + "url": "http://example.com/gitlab-org/gitlab-test/issues/17#note_1241" + }, + "issue": { + "id": 92, + "title": "test", + "assignee_id": None, + "author_id": 1, + "project_id": 5, + "created_at": "2015-04-12 14:53:17 UTC", + "updated_at": "2015-04-26 08:28:42 UTC", + "position": 0, + "branch_name": None, + "description": "test", + "milestone_id": None, + "state": "closed", + "iid": 17, + "url": "http://example.com/gitlab-org/gitlab-test/issues/17" + } +} + +# +# SIGNATURE, INVALID IP, BLOCKED PROJECT... +# + +def test_bad_signature(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "badbadbad") + data = {} + response = client.post(url, json.dumps(data), content_type="application/json") + response_content = response.data + assert response.status_code == 400 + assert "Bad signature" in response_content["_error_message"] + + +def test_ok_signature(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["111.111.111.111"], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, + json.dumps(data), + content_type="application/json", + REMOTE_ADDR="111.111.111.111") + + assert response.status_code == 204 + + +def test_ok_empty_payload(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["111.111.111.111"], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + response = client.post(url, "null", content_type="application/json", + REMOTE_ADDR="111.111.111.111") + + assert response.status_code == 204 + + +def test_ok_signature_ip_in_network(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["111.111.111.0/24"], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, json.dumps(data), + content_type="application/json", + REMOTE_ADDR="111.111.111.112") + + assert response.status_code == 204 + + +def test_ok_signature_invalid_network(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["131.103.20.160/27;165.254.145.0/26;104.192.143.0/24"], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = json.dumps({"push": {"changes": [{"new": {"target": { "message": "test message"}}}]}}) + response = client.post(url, + data, + content_type="application/json", + HTTP_X_EVENT_KEY="repo:push", + REMOTE_ADDR="104.192.143.193") + + assert response.status_code == 400 + assert "Bad signature" in response.data["_error_message"] + + + +def test_blocked_project(client): + project = f.ProjectFactory(blocked_code=project_choices.BLOCKED_BY_STAFF) + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["111.111.111.111"], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, + json.dumps(data), + content_type="application/json", + REMOTE_ADDR="111.111.111.111") + + assert response.status_code == 451 + + +def test_invalid_ip(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["111.111.111.111"], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, + json.dumps(data), + content_type="application/json", + REMOTE_ADDR="111.111.111.112") + + assert response.status_code == 400 + + +def test_invalid_origin_ip_settings(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["testing"] + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, + json.dumps(data), + content_type="application/json", + REMOTE_ADDR="111.111.111.112") + + assert response.status_code == 400 + + +def test_valid_local_network_ip(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["192.168.1.1"], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, + json.dumps(data), + content_type="application/json", + REMOTE_ADDR="192.168.1.1") + + assert response.status_code == 204 + + +def test_not_ip_filter(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": [], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, + json.dumps(data), + content_type="application/json", + REMOTE_ADDR="111.111.111.111") + + assert response.status_code == 204 + + +# +# PUSH EVENTS +# +def test_push_event_detected(client): + project = f.ProjectFactory() + url = reverse("gitlab-hook-list") + url = "%s?project=%s" % (url, project.id) + data = deepcopy(push_base_payload) + data["commits"] = [{ + "message": "test message", + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + data["total_commits_count"] = 1 + + GitLabViewSet._validate_signature = mock.Mock(return_value=True) + + with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock: + response = client.post(url, json.dumps(data), + HTTP_X_GITHUB_EVENT="push", + content_type="application/json") + + assert process_event_mock.call_count == 1 + + assert response.status_code == 204 + + +def test_push_event_epic_processing(client): + creation_status = f.EpicStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_epics"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.EpicStatusFactory(project=creation_status.project) + epic = f.EpicFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s #%s ok + bye! + """ % (epic.ref, new_status.slug), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(epic.project, payload) + ev_hook.process_event() + epic = Epic.objects.get(id=epic.id) + assert epic.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_issue_processing(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s #%s ok + bye! + """ % (issue.ref, new_status.slug), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue = Issue.objects.get(id=issue.id) + assert issue.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_processing(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s #%s ok + bye! + """ % (task.ref, new_status.slug), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_processing(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.UserStoryStatusFactory(project=creation_status.project) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s #%s ok + bye! + """ % (user_story.ref, new_status.slug), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + user_story = UserStory.objects.get(id=user_story.id) + assert user_story.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_issue_mention(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(issue, user=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s ok + bye! + """ % (issue.ref), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue_history = get_history_queryset_by_model_instance(issue) + assert issue_history.count() == 1 + assert issue_history[0].comment.startswith("This issue has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_task_mention(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(task, user=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s ok + bye! + """ % (task.ref), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task_history = get_history_queryset_by_model_instance(task) + assert task_history.count() == 1 + assert task_history[0].comment.startswith("This task has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_mention(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(user_story, user=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s ok + bye! + """ % (user_story.ref), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + us_history = get_history_queryset_by_model_instance(user_story) + assert us_history.count() == 1 + assert us_history[0].comment.startswith("This user story has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_multiple_actions(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue1 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + issue2 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s #%s ok + test TG-%s #%s ok + bye! + """ % (issue1.ref, new_status.slug, issue2.ref, new_status.slug), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + mail.outbox = [] + ev_hook1 = event_hooks.PushEventHook(issue1.project, payload) + ev_hook1.process_event() + issue1 = Issue.objects.get(id=issue1.id) + issue2 = Issue.objects.get(id=issue2.id) + assert issue1.status.id == new_status.id + assert issue2.status.id == new_status.id + assert len(mail.outbox) == 2 + + +def test_push_event_processing_case_insensitive(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test tg-%s #%s ok + bye! + """ % (task.ref, new_status.slug.upper()), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_bad_processing_non_existing_ref(client): + issue_status = f.IssueStatusFactory() + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-6666666 #%s ok + bye! + """ % (issue_status.slug), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue_status.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The referenced element doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_us_bad_processing_non_existing_status(client): + user_story = f.UserStoryFactory.create() + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s #non-existing-slug ok + bye! + """ % (user_story.ref), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_bad_processing_non_existing_status(client): + issue = f.IssueFactory.create() + payload = deepcopy(push_base_payload) + payload["commits"] = [{ + "message": """test message + test TG-%s #non-existing-slug ok + bye! + """ % (issue.ref), + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + }] + payload["total_commits_count"] = 1 + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +# +# ISSUE EVENTS: CREATE +# +def test_issues_event_opened_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + payload = deepcopy(new_issue_base_payload) + payload["object_attributes"]["title"] = "test-title" + payload["object_attributes"]["description"] = "test-body" + payload["object_attributes"]["url"] = "http://gitlab.com/test/project/issues/11" + payload["object_attributes"]["action"] = "open" + payload["repository"]["homepage"] = "test" + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 2 + assert len(mail.outbox) == 1 + + +def test_issues_event_bad_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + payload = deepcopy(new_issue_base_payload) + del payload["object_attributes"]["title"] + del payload["object_attributes"]["description"] + del payload["object_attributes"]["url"] + payload["object_attributes"]["action"] = "open" + payload["repository"]["homepage"] = "test" + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "Invalid issue information" + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + +# +# ISSUE EVENTS: UPDATE +# +def test_issues_event_updated_issue_connected_with_external_one(client): + issue = f.IssueFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/112"]) + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + + payload = deepcopy(edit_issue_base_payload) + payload["object_attributes"]["title"] = "test-title" + payload["object_attributes"]["description"] = "test-body" + payload["object_attributes"]["url"] = "http://gitlab.com/test/project/issues/112" + payload["object_attributes"]["action"] = "update" + payload["repository"]["homepage"] = "test" + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 1 + + assert issue.subject != payload["object_attributes"]["title"] + assert issue.description != payload["object_attributes"]["description"] + + issue.refresh_from_db() + + assert issue.subject == payload["object_attributes"]["title"] + assert issue.description == payload["object_attributes"]["description"] + + +def test_issues_event_updated_issue_without_connection(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + payload = deepcopy(edit_issue_base_payload) + payload["object_attributes"]["title"] = "test-title" + payload["object_attributes"]["description"] = "test-body" + payload["object_attributes"]["url"] = "http://gitlab.com/test/project/issues/11" + payload["object_attributes"]["action"] = "update" + payload["repository"]["homepage"] = "test" + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 2 + assert len(mail.outbox) == 1 + +# +# ISSUE EVENTS: OPEN +# +def test_issues_event_opened_issue_connected_with_external_one(client): + issue = f.IssueFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/112"]) + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + close_status = f.IssueStatusFactory(project=issue.project, is_closed=True) + issue.status = close_status + issue.save() + + payload = deepcopy(close_issue_base_payload) + payload["object_attributes"]["url"] = "http://gitlab.com/test/project/issues/112" + payload["object_attributes"]["state"] = "opened" + payload["object_attributes"]["action"] = "reopen" + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 1 + + assert issue.status == close_status + + issue.refresh_from_db() + + assert issue.status == issue.project.default_issue_status + + +def test_issues_event_updated_issue_without_connection(client): + issue = f.IssueFactory.create(external_reference=[]) + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + payload = deepcopy(close_issue_base_payload) + payload["object_attributes"]["url"] = "http://gitlab.com/test/project/issues/112" + payload["object_attributes"]["state"] = "opened" + payload["object_attributes"]["action"] = "reopen" + + mail.outbox = [] + + assert Issue.objects.count() == 1 + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 2 + assert len(mail.outbox) == 1 + + assert Issue.objects.all().order_by("id").last().status == issue.project.default_issue_status + + +# +# ISSUE EVENTS: CLOSE +# +def test_issues_event_closed_issue_conected_with_external_one_without_close_status_defined(client): + issue = f.IssueFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/112"]) + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + close_status = f.IssueStatusFactory(project=issue.project, is_closed=True) + f.ProjectModulesConfigFactory(project=issue.project, config={ + "gitlab": {} + }) + + payload = deepcopy(close_issue_base_payload) + payload["object_attributes"]["url"] = "http://gitlab.com/test/project/issues/112" + payload["object_attributes"]["state"] = "closed" + payload["object_attributes"]["action"] = "close" + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 1 + + assert issue.status == issue.project.default_issue_status + + issue.refresh_from_db() + + assert issue.status == close_status + + +def test_issues_event_closed_issue_conected_with_external_one_with_close_status_defined(client): + issue = f.IssueFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/112"]) + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + close_status = f.IssueStatusFactory(project=issue.project, is_closed=True) + f.ProjectModulesConfigFactory(project=issue.project, config={ + "gitlab": { + "close_status": close_status.id, + } + }) + + payload = deepcopy(close_issue_base_payload) + payload["object_attributes"]["url"] = "http://gitlab.com/test/project/issues/112" + payload["object_attributes"]["state"] = "closed" + payload["object_attributes"]["action"] = "close" + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 1 + + assert issue.status == issue.project.default_issue_status + + issue.refresh_from_db() + + assert issue.status == close_status + + +def test_issues_event_closed_issue_without_connection(client): + issue = f.IssueFactory.create(external_reference=[]) + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_admin=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.all + notify_policy.save() + + close_status = f.IssueStatusFactory(project=issue.project, is_closed=True) + + payload = deepcopy(close_issue_base_payload) + payload["object_attributes"]["url"] = "http://gitlab.com/test/project/issues/112" + payload["object_attributes"]["state"] = "closed" + payload["object_attributes"]["action"] = "close" + + mail.outbox = [] + + assert Issue.objects.count() == 1 + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 2 + assert len(mail.outbox) == 1 + + assert Issue.objects.all().order_by("id").last().status == close_status + + +# +# ISSUE COMMENT EVENTS +# +def test_issue_comment_event_on_existing_issue_task_and_us(client): + project = f.ProjectFactory() + role = f.RoleFactory(project=project, permissions=["view_tasks", "view_issues", "view_us"]) + f.MembershipFactory(project=project, role=role, user=project.owner) + user = f.UserFactory() + + issue = f.IssueFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/11"], owner=project.owner, project=project) + take_snapshot(issue, user=user) + task = f.TaskFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/11"], owner=project.owner, project=project) + take_snapshot(task, user=user) + us = f.UserStoryFactory.create(external_reference=["gitlab", "http://gitlab.com/test/project/issues/11"], owner=project.owner, project=project) + take_snapshot(us, user=user) + + payload = deepcopy(issue_comment_base_payload) + payload["user"]["username"] = "test" + payload["issue"]["iid"] = "11" + payload["issue"]["title"] = "test-title" + payload["issue"]["url"] = "http://gitlab.com/test/project/issues/11" + payload["object_attributes"]["noteable_type"] = "Issue" + payload["object_attributes"]["note"] = "Test body" + payload["repository"]["homepage"] = "http://gitlab.com/test/project" + + mail.outbox = [] + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + ev_hook.process_event() + + issue_history = get_history_queryset_by_model_instance(issue) + assert issue_history.count() == 1 + assert "Test body" in issue_history[0].comment + + task_history = get_history_queryset_by_model_instance(task) + assert task_history.count() == 1 + assert "Test body" in issue_history[0].comment + + us_history = get_history_queryset_by_model_instance(us) + assert us_history.count() == 1 + assert "Test body" in issue_history[0].comment + + assert len(mail.outbox) == 3 + + +def test_issue_comment_event_on_not_existing_issue_task_and_us(client): + issue = f.IssueFactory.create(external_reference=["github", "10"]) + take_snapshot(issue, user=issue.owner) + task = f.TaskFactory.create(project=issue.project, external_reference=["gitlab", "http://gitlab.com/test/project/issues/1110"]) + take_snapshot(task, user=task.owner) + us = f.UserStoryFactory.create(project=issue.project) + take_snapshot(us, user=us.owner) + + payload = deepcopy(issue_comment_base_payload) + payload["user"]["username"] = "test" + payload["issue"]["iid"] = "99999" + payload["issue"]["title"] = "test-title" + payload["issue"]["url"] = "http://gitlab.com/test/project/issues/11" + payload["object_attributes"]["noteable_type"] = "Issue" + payload["object_attributes"]["note"] = "test comment" + payload["repository"]["homepage"] = "test" + + mail.outbox = [] + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + ev_hook.process_event() + + assert get_history_queryset_by_model_instance(issue).count() == 0 + assert get_history_queryset_by_model_instance(task).count() == 0 + assert get_history_queryset_by_model_instance(us).count() == 0 + + assert len(mail.outbox) == 0 + + +def test_issues_event_bad_comment(client): + issue = f.IssueFactory.create(external_reference=["gitlab", "10"]) + take_snapshot(issue, user=issue.owner) + + payload = deepcopy(issue_comment_base_payload) + payload["user"]["username"] = "test" + payload["issue"]["iid"] = "10" + payload["issue"]["title"] = "test-title" + payload["object_attributes"]["noteable_type"] = "Issue" + del payload["object_attributes"]["note"] + payload["repository"]["homepage"] = "test" + + ev_hook = event_hooks.IssueCommentEventHook(issue.project, payload) + + mail.outbox = [] + + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "Invalid issue comment information" + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + +def test_api_get_project_modules(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 200 + content = response.data + assert "gitlab" in content + assert content["gitlab"]["secret"] != "" + assert content["gitlab"]["webhooks_url"] != "" + + +def test_api_patch_project_modules(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + data = { + "gitlab": { + "secret": "test_secret", + "url": "test_url", + } + } + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 204 + + config = services.get_modules_config(project).config + assert "gitlab" in config + assert config["gitlab"]["secret"] == "test_secret" + assert config["gitlab"]["webhooks_url"] != "test_url" + + +def test_replace_gitlab_references(): + ev_hook = event_hooks.BaseGitLabEventHook + assert ev_hook.replace_gitlab_references(None, "project-url", "#2") == "[GitLab#2](project-url/issues/2)" + assert ev_hook.replace_gitlab_references(None, "project-url", "#2 ") == "[GitLab#2](project-url/issues/2) " + assert ev_hook.replace_gitlab_references(None, "project-url", " #2 ") == " [GitLab#2](project-url/issues/2) " + assert ev_hook.replace_gitlab_references(None, "project-url", " #2") == " [GitLab#2](project-url/issues/2)" + assert ev_hook.replace_gitlab_references(None, "project-url", "#test") == "#test" + assert ev_hook.replace_gitlab_references(None, "project-url", None) == "" + + +def test_signal_handlers_move_on_destroy_with_not_assigned_status(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + close_status_1 = f.IssueStatusFactory(project=project, is_closed=True) + close_status_2 = f.IssueStatusFactory(project=project, is_closed=True) + + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": {} + }) + + url = reverse("issue-statuses-detail", kwargs={"pk": close_status_1.pk}) + "?moveTo={}".format(close_status_2.pk) + + client.login(project.owner) + + assert project.issue_statuses.count() == 3 + + response = client.delete(url) + + assert response.status_code == 204 + assert project.issue_statuses.count() == 2 + assert project.modules_config.config.get("gitlab", {}).get("close_status", None) == None + + +def test_signal_handlers_move_on_destroy_with_assigned_status(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + close_status_1 = f.IssueStatusFactory(project=project, is_closed=True) + close_status_2 = f.IssueStatusFactory(project=project, is_closed=True) + + modules_config = f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "close_status": close_status_1.id, + } + }) + + url = reverse("issue-statuses-detail", kwargs={"pk": close_status_1.pk}) + "?moveTo={}".format(close_status_2.pk) + + client.login(project.owner) + + assert project.issue_statuses.count() == 3 + assert modules_config.config.get("gitlab", {}).get("close_status", None) == close_status_1.id + + response = client.delete(url) + modules_config.refresh_from_db() + + assert response.status_code == 204 + assert project.issue_statuses.count() == 2 + assert modules_config.config.get("gitlab", {}).get("close_status", None) == close_status_2.id + + +def test_signal_handlers_move_on_destroy_with_different_assigned_status(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + close_status_1 = f.IssueStatusFactory(project=project, is_closed=True) + close_status_2 = f.IssueStatusFactory(project=project, is_closed=True) + close_status_3 = f.IssueStatusFactory(project=project, is_closed=True) + + modules_config = f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "close_status": close_status_3.id, + } + }) + + url = reverse("issue-statuses-detail", kwargs={"pk": close_status_1.pk}) + "?moveTo={}".format(close_status_2.pk) + + client.login(project.owner) + + assert project.issue_statuses.count() == 4 + assert modules_config.config.get("gitlab", {}).get("close_status", None) == close_status_3.id + + response = client.delete(url) + modules_config.refresh_from_db() + + assert response.status_code == 204 + assert project.issue_statuses.count() == 3 + assert modules_config.config.get("gitlab", {}).get("close_status", None) == close_status_3.id diff --git a/tests/integration/test_hooks_gogs.py b/tests/integration/test_hooks_gogs.py new file mode 100644 index 000000000..47731832d --- /dev/null +++ b/tests/integration/test_hooks_gogs.py @@ -0,0 +1,521 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from unittest import mock + +from django.urls import reverse +from django.core import mail + +from taiga.base.utils import json +from taiga.hooks.gogs import event_hooks +from taiga.hooks.gogs.api import GogsViewSet +from taiga.hooks.exceptions import ActionSyntaxException +from taiga.projects import choices as project_choices +from taiga.projects.epics.models import Epic +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.models import Membership +from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import NotifyPolicy +from taiga.projects import services +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_bad_signature(client): + project = f.ProjectFactory() + url = reverse("gogs-hook-list") + url = "%s?project=%s" % (url, project.id) + data = { + "secret": "badbadbad" + } + response = client.post(url, json.dumps(data), + content_type="application/json") + response_content = response.data + assert response.status_code == 400 + assert "Bad signature" in response_content["_error_message"] + + +def test_ok_signature(client): + project = f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gogs": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("gogs-hook-list") + url = "%s?project=%s" % (url, project.id) + data = {"test:": "data", "secret": "tpnIwJDz4e"} + response = client.post(url, json.dumps(data), + content_type="application/json") + + assert response.status_code == 204 + + +def test_blocked_project(client): + project = f.ProjectFactory(blocked_code=project_choices.BLOCKED_BY_STAFF) + f.ProjectModulesConfigFactory(project=project, config={ + "gogs": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("gogs-hook-list") + url = "%s?project=%s" % (url, project.id) + data = {"test:": "data", "secret": "tpnIwJDz4e"} + response = client.post(url, json.dumps(data), + content_type="application/json") + + assert response.status_code == 451 + + +def test_push_event_detected(client): + project = f.ProjectFactory() + url = reverse("gogs-hook-list") + url = "%s?project=%s" % (url, project.id) + data = { + "commits": [ + { + "message": "test message", + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + + GogsViewSet._validate_signature = mock.Mock(return_value=True) + + with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock: + response = client.post(url, json.dumps(data), + HTTP_X_GITHUB_EVENT="push", + content_type="application/json") + + assert process_event_mock.call_count == 1 + + assert response.status_code == 204 + + +def test_push_event_epic_processing(client): + creation_status = f.EpicStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_epics"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.EpicStatusFactory(project=creation_status.project) + epic = f.EpicFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s #%s ok + bye! + """ % (epic.ref, new_status.slug), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(epic.project, payload) + ev_hook.process_event() + epic = Epic.objects.get(id=epic.id) + assert epic.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_issue_processing(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s #%s ok + bye! + """ % (issue.ref, new_status.slug), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue = Issue.objects.get(id=issue.id) + assert issue.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_processing(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s #%s ok + bye! + """ % (task.ref, new_status.slug), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_processing(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.UserStoryStatusFactory(project=creation_status.project) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s #%s ok + bye! + """ % (user_story.ref, new_status.slug), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + user_story = UserStory.objects.get(id=user_story.id) + assert user_story.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_issue_mention(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(issue, user=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s ok + bye! + """ % (issue.ref), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue_history = get_history_queryset_by_model_instance(issue) + assert issue_history.count() == 1 + assert issue_history[0].comment.startswith("This issue has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_task_mention(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(task, user=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s ok + bye! + """ % (task.ref), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task_history = get_history_queryset_by_model_instance(task) + assert task_history.count() == 1 + assert task_history[0].comment.startswith("This task has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_mention(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + take_snapshot(user_story, user=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s ok + bye! + """ % (user_story.ref), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + us_history = get_history_queryset_by_model_instance(user_story) + assert us_history.count() == 1 + assert us_history[0].comment.startswith("This user story has been mentioned by") + assert len(mail.outbox) == 1 + + +def test_push_event_multiple_actions(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue1 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + issue2 = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test TG-%s #%s ok + test TG-%s #%s ok + bye! + """ % (issue1.ref, new_status.slug, issue2.ref, new_status.slug), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook1 = event_hooks.PushEventHook(issue1.project, payload) + ev_hook1.process_event() + issue1 = Issue.objects.get(id=issue1.id) + issue2 = Issue.objects.get(id=issue2.id) + assert issue1.status.id == new_status.id + assert issue2.status.id == new_status.id + assert len(mail.outbox) == 2 + + +def test_push_event_processing_case_insensitive(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = { + "commits": [ + { + "message": """test message + test tg-%s #%s ok + bye! + """ % (task.ref, new_status.slug.upper()), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_bad_processing_non_existing_ref(client): + issue_status = f.IssueStatusFactory() + payload = { + "commits": [ + { + "message": """test message + test TG-6666666 #%s ok + bye! + """ % (issue_status.slug), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue_status.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The referenced element doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_us_bad_processing_non_existing_status(client): + user_story = f.UserStoryFactory.create() + payload = { + "commits": [ + { + "message": """test message + test TG-%s #non-existing-slug ok + bye! + """ % (user_story.ref), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_bad_processing_non_existing_status(client): + issue = f.IssueFactory.create() + payload = { + "commits": [ + { + "message": """test message + test TG-%s #non-existing-slug ok + bye! + """ % (issue.ref), + "author": { + "username": "test", + }, + } + ], + "repository": { + "html_url": "http://test-url/test/project" + } + } + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_api_get_project_modules(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 200 + content = response.data + assert "gogs" in content + assert content["gogs"]["secret"] != "" + assert content["gogs"]["webhooks_url"] != "" + + +def test_api_patch_project_modules(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + data = { + "gogs": { + "secret": "test_secret", + "html_url": "test_url", + } + } + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 204 + + config = services.get_modules_config(project).config + assert "gogs" in config + assert config["gogs"]["secret"] == "test_secret" + assert config["gogs"]["webhooks_url"] != "test_url" + + +def test_replace_gogs_references(): + ev_hook = event_hooks.BaseGogsEventHook + assert ev_hook.replace_gogs_references(None, "project-url", "#2") == "[Gogs#2](project-url/issues/2)" + assert ev_hook.replace_gogs_references(None, "project-url", "#2 ") == "[Gogs#2](project-url/issues/2) " + assert ev_hook.replace_gogs_references(None, "project-url", " #2 ") == " [Gogs#2](project-url/issues/2) " + assert ev_hook.replace_gogs_references(None, "project-url", " #2") == " [Gogs#2](project-url/issues/2)" + assert ev_hook.replace_gogs_references(None, "project-url", "#test") == "#test" + assert ev_hook.replace_gogs_references(None, "project-url", None) == "" diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py new file mode 100644 index 000000000..87739ed2d --- /dev/null +++ b/tests/integration/test_importer_api.py @@ -0,0 +1,1894 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import base64 +import logging + +from django.apps import apps +from django.urls import reverse +from django.core.files.base import ContentFile + +from taiga.base.utils import json +from taiga.export_import import services +from taiga.export_import.exceptions import TaigaImportError +from taiga.projects.models import Project, Membership +from taiga.projects.issues.models import Issue +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task +from taiga.projects.wiki.models import WikiPage + +from .. import factories as f +from ..utils import DUMMY_BMP_DATA + +pytestmark = pytest.mark.django_db + + + +####################################################### +## test api/v1/importer +####################################################### + +def test_invalid_project_import(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-list") + data = {} + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_valid_project_import_without_extra_data(client): + user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") + client.login(user) + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "roles": [{"name": "Role"}], + "watchers": ["testing@taiga.io"] + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + must_empty_children = [ + "issues", "user_stories", "us_statuses", "us_duedates", "wiki_pages", + "priorities", "severities", "milestones", "points", "issue_types", + "task_statuses", "task_duedates", "issue_statuses", "issue_duedates", + "wiki_links" + ] + assert all(map(lambda x: len(response.data[x]) == 0, must_empty_children)) + assert response.data["owner"] == user.email + assert response.data["watchers"] == [user.email, user_watching.email] + + +def test_valid_project_without_enough_public_projects_slots(client): + user = f.UserFactory.create(max_public_projects=0) + + url = reverse("importer-list") + data = { + "slug": "public-project-without-slots", + "name": "Imported project", + "description": "Imported project", + "roles": [{"name": "Role"}], + "is_private": False + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more public projects" in response.data["_error_message"] + assert Project.objects.filter(slug="public-project-without-slots").count() == 0 + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "False" + + +def test_valid_project_without_enough_private_projects_slots(client): + user = f.UserFactory.create(max_private_projects=0) + + url = reverse("importer-list") + data = { + "slug": "private-project-without-slots", + "name": "Imported project", + "description": "Imported project", + "roles": [{"name": "Role"}], + "is_private": True + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more private projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "True" + assert Project.objects.filter(slug="private-project-without-slots").count() == 0 + + +def test_valid_project_with_enough_public_projects_slots(client): + user = f.UserFactory.create(max_public_projects=1) + + url = reverse("importer-list") + data = { + "slug": "public-project-with-slots", + "name": "Imported project", + "description": "Imported project", + "roles": [{"name": "Role"}], + "is_private": False + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert Project.objects.filter(slug="public-project-with-slots").count() == 1 + + +def test_valid_project_with_enough_private_projects_slots(client): + user = f.UserFactory.create(max_private_projects=1) + + url = reverse("importer-list") + data = { + "slug": "private-project-with-slots", + "name": "Imported project", + "description": "Imported project", + "roles": [{"name": "Role"}], + "is_private": True + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert Project.objects.filter(slug="private-project-with-slots").count() == 1 + + +def test_valid_project_import_with_not_existing_memberships(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "memberships": [{ + "email": "bad@email.com", + "role": "Role", + }], + "roles": [{"name": "Role"}] + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + # The new membership and the owner membership + assert len(response.data["memberships"]) == 2 + + +def test_valid_project_import_with_membership_uuid_rewrite(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "memberships": [{ + "email": "with-uuid@email.com", + "role": "Role", + "token": "123", + }], + "roles": [{"name": "Role"}] + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert Membership.objects.filter(email="with-uuid@email.com", token="123").count() == 0 + + +def test_valid_project_import_with_extra_data(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "roles": [{ + "permissions": [], + "name": "Test" + }], + "us_statuses": [{ + "name": "Test" + }], + "us_duedates": [{ + "name": "Test" + }], + "severities": [{ + "name": "Test" + }], + "priorities": [{ + "name": "Test" + }], + "points": [{ + "name": "Test" + }], + "issue_types": [{ + "name": "Test" + }], + "task_statuses": [{ + "name": "Test" + }], + "task_duedates": [{ + "name": "Test" + }], + "issue_statuses": [{ + "name": "Test" + }], + "issue_duedates": [{ + "name": "Test" + }], + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + must_empty_children = [ + "issues", "user_stories", "wiki_pages", "milestones", + "wiki_links", + ] + + must_one_instance_children = [ + "roles", "us_statuses", "us_duedates", "severities", "priorities", + "points", "issue_types", "task_statuses", "task_duedates", + "issue_statuses", "issue_duedates", "memberships" + ] + + assert all(map(lambda x: len(response.data[x]) == 0, must_empty_children)) + # Allwais is created at least the owner membership + assert all(map(lambda x: len(response.data[x]) == 1, must_one_instance_children)) + assert response.data["owner"] == user.email + + +def test_invalid_project_import_without_roles(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 2 + assert Project.objects.filter(slug="imported-project").count() == 0 + + +def test_invalid_project_import_with_extra_data(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "roles": [{ + "permissions": [], + "name": "Test" + }], + "us_statuses": [{}], + "us_duedates": [{}], + "severities": [{}], + "priorities": [{}], + "points": [{}], + "issue_types": [{}], + "task_statuses": [{}], + "task_duedates": [{}], + "issue_duedates": [{}], + "issue_statuses": [{}], + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 10 + assert Project.objects.filter(slug="imported-project").count() == 0 + + +def test_valid_project_import_with_custom_attributes(client): + user = f.UserFactory.create() + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "roles": [{ + "permissions": [], + "name": "Test" + }], + "userstorycustomattributes": [{ + "name": "custom attribute example 1", + "description": "short description 1", + "order": 1 + }], + "taskcustomattributes": [{ + "name": "custom attribute example 1", + "description": "short description 1", + "order": 1 + }], + "issuecustomattributes": [{ + "name": "custom attribute example 1", + "description": "short description 1", + "order": 1 + }] + } + + must_empty_children = ["issues", "user_stories", "wiki_pages", "milestones", "wiki_links"] + must_one_instance_children = ["userstorycustomattributes", "taskcustomattributes", "issuecustomattributes"] + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert all(map(lambda x: len(response.data[x]) == 0, must_empty_children)) + # Allwais is created at least the owner membership + assert all(map(lambda x: len(response.data[x]) == 1, must_one_instance_children)) + assert response.data["owner"] == user.email + + +def test_invalid_project_import_with_custom_attributes(client): + user = f.UserFactory.create() + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "roles": [{ + "permissions": [], + "name": "Test" + }], + "userstorycustomattributes": [{ }], + "taskcustomattributes": [{ }], + "issuecustomattributes": [{ }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 3 + assert Project.objects.filter(slug="imported-project").count() == 0 + + +####################################################### +## tes api/v1/importer/milestone +####################################################### + +def test_invalid_milestone_import(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-milestone", args=[project.pk]) + data = {} + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_valid_milestone_import(client): + user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-milestone", args=[project.pk]) + data = { + "name": "Imported milestone", + "estimated_start": "2014-10-10", + "estimated_finish": "2014-10-20", + "watchers": ["testing@taiga.io"] + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data["watchers"] == [user_watching.email] + +def test_milestone_import_duplicated_milestone(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-milestone", args=[project.pk]) + data = { + "name": "Imported milestone", + "estimated_start": "2014-10-10", + "estimated_finish": "2014-10-20", + } + # We create twice the same milestone + response = client.json.post(url, json.dumps(data)) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert response.data["milestones"][0]["name"][0] == "Duplicated name" + + + +####################################################### +## tes api/v1/importer/us +####################################################### + +def test_invalid_us_import(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-us", args=[project.pk]) + data = {} + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_valid_us_import_without_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-us", args=[project.pk]) + data = { + "subject": "Test" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data["owner"] == user.email + assert response.data["ref"] is not None + + +def test_valid_us_import_with_extra_data(client): + user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-us", args=[project.pk]) + data = { + "subject": "Imported us", + "description": "Imported us", + "attachments": [{ + "owner": user.email, + "attached_file": { + "name": "imported attachment", + "data": base64.b64encode(b"TEST").decode("utf-8") + } + }], + "watchers": ["testing@taiga.io"] + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert len(response.data["attachments"]) == 1 + assert response.data["owner"] == user.email + assert response.data["ref"] is not None + assert response.data["watchers"] == [user_watching.email] + + +def test_invalid_us_import_with_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-us", args=[project.pk]) + data = { + "subject": "Imported us", + "description": "Imported us", + "attachments": [{}], + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + assert UserStory.objects.filter(subject="Imported us").count() == 0 + + +def test_invalid_us_import_with_bad_choices(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-us", args=[project.pk]) + data = { + "subject": "Imported us", + "description": "Imported us", + "status": "Not valid" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + + +####################################################### +## tes api/v1/importer/task +####################################################### + +def test_invalid_task_import(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-task", args=[project.pk]) + data = {} + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_valid_task_import_without_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-task", args=[project.pk]) + data = { + "subject": "Test" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data["owner"] == user.email + assert response.data["ref"] is not None + + +def test_valid_task_import_with_custom_attributes_values(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + custom_attr = f.TaskCustomAttributeFactory(project=project) + + url = reverse("importer-task", args=[project.pk]) + data = { + "subject": "Test Custom Attrs Values Tasks", + "custom_attributes_values": { + custom_attr.name: "test_value" + } + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + custom_attributes_values = apps.get_model("custom_attributes.TaskCustomAttributesValues").objects.get( + task__subject=response.data["subject"]) + assert custom_attributes_values.attributes_values == {str(custom_attr.id): "test_value"} + + +def test_valid_task_import_with_extra_data(client): + user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-task", args=[project.pk]) + data = { + "subject": "Imported task", + "description": "Imported task", + "attachments": [{ + "owner": user.email, + "attached_file": { + "name": "imported attachment", + "data": base64.b64encode(b"TEST").decode("utf-8") + } + }], + "watchers": ["testing@taiga.io"] + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert len(response.data["attachments"]) == 1 + assert response.data["owner"] == user.email + assert response.data["ref"] is not None + assert response.data["watchers"] == [user_watching.email] + + +def test_invalid_task_import_with_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-task", args=[project.pk]) + data = { + "subject": "Imported task", + "description": "Imported task", + "attachments": [{}], + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + assert Task.objects.filter(subject="Imported task").count() == 0 + + +def test_invalid_task_import_with_bad_choices(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-task", args=[project.pk]) + data = { + "subject": "Imported task", + "description": "Imported task", + "status": "Not valid" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + + +def test_valid_task_with_user_story(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_task_status = f.TaskStatusFactory.create(project=project) + us = f.UserStoryFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-task", args=[project.pk]) + data = { + "subject": "Imported task", + "description": "Imported task", + "user_story": us.ref + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert us.tasks.all().count() == 1 + + +####################################################### +## tes api/v1/importer/issue +####################################################### + +def test_invalid_issue_import(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-issue", args=[project.pk]) + data = {} + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_valid_user_story_import(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-us", args=[project.pk]) + data = { + "subject": "Imported issue", + "finish_date": "2014-10-24T00:00:00+0000" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data["subject"] == "Imported issue" + assert response.data["finish_date"] == "2014-10-24T00:00:00+0000" + + +def test_valid_user_story_import_with_custom_attributes_values(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.save() + custom_attr = f.UserStoryCustomAttributeFactory(project=project) + + url = reverse("importer-us", args=[project.pk]) + data = { + "subject": "Test Custom Attrs Values User Story", + "custom_attributes_values": { + custom_attr.name: "test_value" + } + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + custom_attributes_values = apps.get_model("custom_attributes.UserStoryCustomAttributesValues").objects.get( + user_story__subject=response.data["subject"]) + assert custom_attributes_values.attributes_values == {str(custom_attr.id): "test_value"} + + +def test_valid_issue_import_without_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Test" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data["owner"] == user.email + assert response.data["ref"] is not None + + +def test_valid_issue_import_with_custom_attributes_values(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.save() + custom_attr = f.IssueCustomAttributeFactory(project=project) + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Test Custom Attrs Values Issues", + "custom_attributes_values": { + custom_attr.name: "test_value" + } + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + custom_attributes_values = apps.get_model("custom_attributes.IssueCustomAttributesValues").objects.get( + issue__subject=response.data["subject"]) + assert custom_attributes_values.attributes_values == {str(custom_attr.id): "test_value"} + + +def test_valid_issue_import_with_extra_data(client): + user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Imported issue", + "description": "Imported issue", + "finished_date": "2014-10-24T00:00:00+0000", + "attachments": [{ + "owner": user.email, + "attached_file": { + "name": "imported attachment", + "data": base64.b64encode(b"TEST").decode("utf-8") + } + }], + "watchers": ["testing@taiga.io"] + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert len(response.data["attachments"]) == 1 + assert response.data["owner"] == user.email + assert response.data["ref"] is not None + assert response.data["finished_date"] == "2014-10-24T00:00:00+0000" + assert response.data["watchers"] == [user_watching.email] + + +def test_invalid_issue_import_with_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Imported issue", + "description": "Imported issue", + "attachments": [{}], + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + assert Issue.objects.filter(subject="Imported issue").count() == 0 + + +def test_invalid_issue_import_with_bad_choices(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.save() + client.login(user) + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Imported issue", + "description": "Imported issue", + "status": "Not valid" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Imported issue", + "description": "Imported issue", + "priority": "Not valid" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Imported issue", + "description": "Imported issue", + "severity": "Not valid" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Imported issue", + "description": "Imported issue", + "type": "Not valid" + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + + +####################################################### +## tes api/v1/importer/wiki-page +####################################################### + +def test_invalid_wiki_page_import(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-wiki-page", args=[project.pk]) + data = {} + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_valid_wiki_page_import_without_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-wiki-page", args=[project.pk]) + data = { + "slug": "imported-wiki-page", + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data["owner"] == user.email + + +def test_valid_wiki_page_import_with_extra_data(client): + user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-wiki-page", args=[project.pk]) + data = { + "slug": "imported-wiki-page", + "content": "Imported wiki_page", + "attachments": [{ + "owner": user.email, + "attached_file": { + "name": "imported attachment", + "data": base64.b64encode(b"TEST").decode("utf-8") + } + }], + "watchers": ["testing@taiga.io"] + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert len(response.data["attachments"]) == 1 + assert response.data["owner"] == user.email + assert response.data["watchers"] == [user_watching.email] + + +def test_invalid_wiki_page_import_with_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-wiki-page", args=[project.pk]) + data = { + "slug": "imported-wiki-page", + "content": "Imported wiki_page", + "attachments": [{}], + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 1 + assert WikiPage.objects.filter(slug="imported-wiki-page").count() == 0 + + +####################################################### +## tes api/v1/importer/wiki-link +####################################################### + +def test_invalid_wiki_link_import(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-wiki-link", args=[project.pk]) + data = {} + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_valid_wiki_link_import(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_admin=True) + client.login(user) + + url = reverse("importer-wiki-link", args=[project.pk]) + data = { + "title": "Imported wiki_link", + "href": "imported-wiki-link", + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response.data # TODO: Assert against expectation + + +################################################################## +## tes taiga.export_import.services.store_project_from_dict +################################################################## + +def test_services_store_project_from_dict_with_no_projects_slots_available(client): + user = f.UserFactory.create(max_private_projects=0) + + data = { + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True + } + + with pytest.raises(TaigaImportError) as excinfo: + project = services.store_project_from_dict(data, owner=user) + + assert "can't have more private projects" in str(excinfo.value) + + +def test_services_store_project_from_dict_with_no_members_private_project_slots_available(client): + user = f.UserFactory.create(max_memberships_private_projects=2) + + data = { + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + } + ] + } + + with pytest.raises(TaigaImportError) as excinfo: + project = services.store_project_from_dict(data, owner=user) + + assert "reaches your current limit of memberships for private" in str(excinfo.value) + + +def test_services_store_project_from_dict_with_no_members_public_project_slots_available(client): + user = f.UserFactory.create(max_memberships_public_projects=2) + + data = { + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + } + ] + } + + with pytest.raises(TaigaImportError) as excinfo: + project = services.store_project_from_dict(data, owner=user) + + assert "reaches your current limit of memberships for public" in str(excinfo.value) + + +def test_services_store_project_from_dict_with_issue_priorities_names_as_None(client): + user = f.UserFactory.create() + data = { + "name": "Imported project", + "description": "Imported project", + "issue_types": [{"name": "Bug"}], + "issue_statuses": [{"name": "New"}], + "priorities": [{"name": "None", "order": 5, "color": "#CC0000"}], + "severities": [{"name": "Normal", "order": 5, "color": "#CC0000"}], + "issues": [{ + "status": "New", + "priority": "None", + "severity": "Normal", + "type": "Bug", + "subject": "Test"}]} + + project = services.store_project_from_dict(data, owner=user) + assert project.issues.first().priority.name == "None" + + +def test_services_store_project_from_dict_project_values_due_dates(client): + user = f.UserFactory.create() + data = { + "name": "Imported project", + "description": "Imported project", + "us_duedates": [ + {"name": "US Default", "order": 1, "by_default": True, "color": "#9dce0a", "days_to_due": None}, + {"name": "US Due soon", "order": 2, "by_default": False, "color": "#ff9900", "days_to_due": 6}, + {"name": "US Past due", "order": 3, "by_default": False, "color": "#ff8a84", "days_to_due": -3} + ], + "task_duedates": [ + {"name": "Task Default", "order": 1, "by_default": True, "color": "#9dce0a", "days_to_due": None}, + {"name": "Task Due soon", "order": 2, "by_default": False, "color": "#ff9900", "days_to_due": 5}, + {"name": "Task Past due", "order": 3, "by_default": False, "color": "#ff8a84", "days_to_due": -2} + ], + "issue_duedates": [ + {"name": "Issue Default", "order": 1, "by_default": True, "color": "#9dce0a", "days_to_due": None}, + {"name": "Issue Due soon", "order": 2, "by_default": False, "color": "#ff9900", "days_to_due": 4}, + {"name": "Issue Past due", "order": 3, "by_default": False, "color": "#ff8a84", "days_to_due": -1} + ], + } + + project = services.store_project_from_dict(data, owner=user) + + us_duedates = project.us_duedates.all() + assert us_duedates[0].name == "US Default" + assert us_duedates[0].days_to_due is None + assert us_duedates[1].name == "US Due soon" + assert us_duedates[1].days_to_due == 6 + assert us_duedates[2].name == "US Past due" + assert us_duedates[2].days_to_due == -3 + + task_duedates = project.task_duedates.all() + assert task_duedates[0].name == "Task Default" + assert task_duedates[0].days_to_due is None + assert task_duedates[1].name == "Task Due soon" + assert task_duedates[1].days_to_due == 5 + assert task_duedates[2].name == "Task Past due" + assert task_duedates[2].days_to_due == -2 + + issue_duedates = project.issue_duedates.all() + assert issue_duedates[0].name == "Issue Default" + assert issue_duedates[0].days_to_due is None + assert issue_duedates[1].name == "Issue Due soon" + assert issue_duedates[1].days_to_due == 4 + assert issue_duedates[2].name == "Issue Past due" + assert issue_duedates[2].days_to_due == -1 + + +################################################################## +## tes api/v1/importer/load-dummp +################################################################## + +def test_invalid_dump_import_error(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(b"test") + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert response.data["_error_message"] == "Invalid dump format" + + +def test_valid_dump_import_error_without_enough_public_projects_slots(client, settings): + user = f.UserFactory.create(max_public_projects=0) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "public-project-without-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "can't have more public projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "False" + assert Project.objects.filter(slug="public-project-without-slots").count() == 0 + + +def test_invalid_dump_json_list(client, settings): + user = f.UserFactory.create(max_public_projects=0) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps([{ + "slug": "public-project-without-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False + }]), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + + + + +def test_valid_dump_import_error_without_enough_private_projects_slots(client, settings): + user = f.UserFactory.create(max_private_projects=0) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "private-project-without-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "can't have more private projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "True" + assert Project.objects.filter(slug="private-project-without-slots").count() == 0 + + +def test_valid_dump_import_error_without_enough_membership_private_project_slots_one_project(client, settings): + user = f.UserFactory.create(max_memberships_private_projects=5) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True, + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + }, + ], + "roles": [{"name": "Role"}] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "reaches your current limit of memberships for private" in response.data["_error_message"] + assert Project.objects.filter(slug="project-without-memberships-slots").count() == 0 + + +def test_valid_dump_import_error_without_enough_membership_public_project_slots_one_project(client, settings): + user = f.UserFactory.create(max_memberships_public_projects=5) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False, + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + }, + ], + "roles": [{"name": "Role"}] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "reaches your current limit of memberships for public" in response.data["_error_message"] + assert Project.objects.filter(slug="project-without-memberships-slots").count() == 0 + + +def test_valid_dump_import_error_without_enough_membership_private_project_slots_multiple_project(client, settings): + user = f.UserFactory.create(max_memberships_private_projects=10) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + } + ] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "reaches your current limit of memberships for private" in response.data["_error_message"] + assert Project.objects.filter(slug="project-without-memberships-slots").count() == 0 + + +def test_valid_dump_import_error_without_enough_membership_public_project_slots_multiple_projects(client, settings): + user = f.UserFactory.create(max_memberships_public_projects=10) + project = f.ProjectFactory.create(owner=user, is_private=False) + f.MembershipFactory.create(project=project, user=user) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + } + ] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "reaches your current limit of memberships for public" in response.data["_error_message"] + assert Project.objects.filter(slug="project-without-memberships-slots").count() == 0 + + +def test_valid_dump_import_with_enough_membership_private_project_slots_multiple_projects(client, settings): + user = f.UserFactory.create(max_memberships_private_projects=10) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + } + ] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + assert "id" in response.data + assert response.data["name"] == "Valid project" + + +def test_valid_dump_import_with_enough_membership_public_project_slots_multiple_projects(client, settings): + user = f.UserFactory.create(max_memberships_public_projects=10) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + } + ] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + assert "id" in response.data + assert response.data["name"] == "Valid project" + + +def test_valid_dump_import_with_the_limit_of_membership_whit_you_for_private_project(client, settings): + user = f.UserFactory.create(max_memberships_private_projects=5) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "private-project-with-memberships-limit-with-you", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True, + "memberships": [ + { + "email": user.email, + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + ], + "roles": [{"name": "Role"}] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + assert Project.objects.filter(slug="private-project-with-memberships-limit-with-you").count() == 1 + + +def test_valid_dump_import_with_the_limit_of_membership_whit_you_for_public_project(client, settings): + user = f.UserFactory.create(max_memberships_public_projects=5) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "public-project-with-memberships-limit-with-you", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False, + "memberships": [ + { + "email": user.email, + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + ], + "roles": [{"name": "Role"}] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + assert Project.objects.filter(slug="public-project-with-memberships-limit-with-you").count() == 1 + + +def test_valid_dump_import_with_celery_disabled(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + assert "id" in response.data + assert response.data["name"] == "Valid project" + + +def test_invalid_dump_import_with_celery_disabled(client, settings): + user = f.UserFactory.create(max_memberships_public_projects=5) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "invalid-project", + "name": "Invalid project", + "description": "Valid project desc", + "is_private": False, + "memberships": [ + { + "email": user.email, + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + ], + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + + +def test_valid_dump_import_with_celery_enabled(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 202 + assert "import_id" in response.data + assert Project.objects.filter(slug="valid-project").count() == 1 + settings.CELERY_ENABLED = False + + +def test_invalid_dump_import_with_celery_enabled(client, settings, caplog): + settings.CELERY_ENABLED = True + user = f.UserFactory.create(max_memberships_public_projects=5) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "invalid-project", + "name": "Invalid project", + "description": "Valid project desc", + "is_private": False, + "memberships": [ + { + "email": user.email, + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + ], + }), "utf-8")) + data.name = "test" + + with caplog.at_level(logging.CRITICAL, logger="taiga.export_import"): # Disable logger + response = client.post(url, {'dump': data}) + + assert response.status_code == 202 + assert "import_id" in response.data + assert Project.objects.filter(slug="invalid-project").count() == 0 + + settings.CELERY_ENABLED = False + + +def test_dump_import_throttling(client, settings): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["import-dump-mode"] = "1/minute" + + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": project.slug, + "name": "Test import", + "description": "Valid project desc", + "is_private": True + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + response = client.post(url, {'dump': data}) + assert response.status_code == 429 + + +def test_valid_dump_import_without_slug(client): + project = f.ProjectFactory.create(slug="existing-slug") + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "name": "Project name", + "description": "Valid project desc", + "is_private": True + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + + +def test_valid_dump_import_with_logo(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False, + "logo": { + "name": "logo.bmp", + "data": base64.b64encode(DUMMY_BMP_DATA).decode("utf-8") + } + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + assert "id" in response.data + assert response.data["name"] == "Valid project" + assert "logo_small_url" in response.data + assert response.data["logo_small_url"] != None + assert "logo_big_url" in response.data + assert response.data["logo_big_url"] != None + + +def test_valid_project_import_and_disabled_is_featured(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "roles": [{ + "permissions": [], + "name": "Test" + }], + "is_featured": True + } + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data["owner"] == user.email + assert response.data["is_featured"] == False + + +def test_dump_import_duplicated_project(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": project.slug, + "name": "Test import", + "description": "Valid project desc", + "is_private": True + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + assert response.data["name"] == "Test import" + assert response.data["slug"] == "{}-test-import".format(user.username) diff --git a/tests/integration/test_importers_asana_api.py b/tests/integration/test_importers_asana_api.py new file mode 100644 index 000000000..7bac4df88 --- /dev/null +++ b/tests/integration/test_importers_asana_api.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json + +from unittest import mock + +from django.urls import reverse + +from .. import factories as f +from taiga.importers import exceptions +from taiga.base.utils import json +from taiga.base import exceptions as exc + + +pytestmark = pytest.mark.django_db + + +def test_auth_url(client, settings): + user = f.UserFactory.create() + client.login(user) + settings.IMPORTERS['asana']['callback_url'] = "http://testserver/url" + settings.IMPORTERS['asana']['app_id'] = "test-id" + settings.IMPORTERS['asana']['app_secret'] = "test-secret" + + url = reverse("importers-asana-auth-url") + + with mock.patch('taiga.importers.asana.api.AsanaImporter') as AsanaImporterMock: + AsanaImporterMock.get_auth_url.return_value = "https://auth_url" + response = client.get(url, content_type="application/json") + assert AsanaImporterMock.get_auth_url.calledWith( + settings.IMPORTERS['asana']['app_id'], + settings.IMPORTERS['asana']['app_secret'], + settings.IMPORTERS['asana']['callback_url'] + ) + + assert response.status_code == 200 + assert 'url' in response.data + assert response.data['url'] == "https://auth_url" + + +def test_authorize(client, settings): + user = f.UserFactory.create() + client.login(user) + + authorize_url = reverse("importers-asana-authorize") + + with mock.patch('taiga.importers.asana.api.AsanaImporter') as AsanaImporterMock: + AsanaImporterMock.get_access_token.return_value = "token" + response = client.post(authorize_url, content_type="application/json", data=json.dumps({"code": "code"})) + assert AsanaImporterMock.get_access_token.calledWith( + settings.IMPORTERS['asana']['app_id'], + settings.IMPORTERS['asana']['app_secret'], + "code" + ) + + assert response.status_code == 200 + assert 'token' in response.data + assert response.data['token'] == "token" + + +def test_authorize_without_code(client): + user = f.UserFactory.create() + client.login(user) + + authorize_url = reverse("importers-asana-authorize") + + response = client.post(authorize_url, content_type="application/json", data=json.dumps({})) + + assert response.status_code == 400 + assert 'token' not in response.data + assert '_error_message' in response.data + assert response.data['_error_message'] == "Code param needed" + + +def test_authorize_with_bad_verify(client, settings): + user = f.UserFactory.create() + client.login(user) + + authorize_url = reverse("importers-asana-authorize") + + with mock.patch('taiga.importers.asana.api.AsanaImporter') as AsanaImporterMock: + AsanaImporterMock.get_access_token.side_effect = exceptions.InvalidRequest() + response = client.post(authorize_url, content_type="application/json", data=json.dumps({"code": "bad"})) + assert AsanaImporterMock.get_access_token.calledWith( + settings.IMPORTERS['asana']['app_id'], + settings.IMPORTERS['asana']['app_secret'], + "bad" + ) + + assert response.status_code == 400 + assert 'token' not in response.data + assert '_error_message' in response.data + assert response.data['_error_message'] == "Invalid Asana API request" + + +def test_import_asana_list_users(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-asana-list-users") + + with mock.patch('taiga.importers.asana.api.AsanaImporter') as AsanaImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "username": "user1", "full_name": "user1", "detected_user": None}, + {"id": 2, "username": "user2", "full_name": "user2", "detected_user": None} + ] + AsanaImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 200 + assert response.data[0]["id"] == 1 + assert response.data[1]["id"] == 2 + + +def test_import_asana_list_users_without_project(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-asana-list-users") + + with mock.patch('taiga.importers.asana.api.AsanaImporter') as AsanaImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "username": "user1", "full_name": "user1", "detected_user": None}, + {"id": 2, "username": "user2", "full_name": "user2", "detected_user": None} + ] + AsanaImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 400 + + +def test_import_asana_list_users_with_problem_on_request(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-asana-list-users") + + with mock.patch('taiga.importers.asana.importer.AsanaClient') as AsanaClientMock: + instance = mock.Mock() + instance.workspaces.find_all.side_effect = exceptions.InvalidRequest() + AsanaClientMock.oauth.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 400 + + +def test_import_asana_list_projects(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-asana-list-projects") + + with mock.patch('taiga.importers.asana.api.AsanaImporter') as AsanaImporterMock: + instance = mock.Mock() + instance.list_projects.return_value = ["project1", "project2"] + AsanaImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 200 + assert response.data[0] == "project1" + assert response.data[1] == "project2" + + +def test_import_asana_list_projects_with_problem_on_request(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-asana-list-projects") + + with mock.patch('taiga.importers.asana.importer.AsanaClient') as AsanaClientMock: + instance = mock.Mock() + instance.workspaces.find_all.side_effect = exc.WrongArguments("Invalid Request") + AsanaClientMock.oauth.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 400 + + +def test_import_asana_project_without_project_id(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-asana-import-project") + + with mock.patch('taiga.importers.asana.tasks.AsanaImporter') as AsanaImporterMock: + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 400 + settings.CELERY_ENABLED = False + + +def test_import_asana_project_with_celery_enabled(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + project = f.ProjectFactory.create(slug="async-imported-project") + client.login(user) + + url = reverse("importers-asana-import-project") + + with mock.patch('taiga.importers.asana.tasks.AsanaImporter') as AsanaImporterMock: + instance = mock.Mock() + instance.import_project.return_value = project + AsanaImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 202 + assert "task_id" in response.data + settings.CELERY_ENABLED = False + + +def test_import_asana_project_with_celery_disabled(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(slug="imported-project") + client.login(user) + + url = reverse("importers-asana-import-project") + + with mock.patch('taiga.importers.asana.api.AsanaImporter') as AsanaImporterMock: + instance = mock.Mock() + instance.import_project.return_value = project + AsanaImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 200 + assert "slug" in response.data + assert response.data['slug'] == "imported-project" diff --git a/tests/integration/test_importers_github_api.py b/tests/integration/test_importers_github_api.py new file mode 100644 index 000000000..80bf0349f --- /dev/null +++ b/tests/integration/test_importers_github_api.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json + +from unittest import mock + +from django.urls import reverse + +from .. import factories as f +from taiga.importers import exceptions +from taiga.base.utils import json +from taiga.base import exceptions as exc + + +pytestmark = pytest.mark.django_db + + +def test_auth_url(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-github-auth-url")+"?uri=http://localhost:9001/project/new?from=github" + + response = client.get(url, content_type="application/json") + + assert response.status_code == 200 + assert 'url' in response.data + assert response.data['url'] == "https://github.com/login/oauth/authorize?client_id=&scope=user,repo&redirect_uri=http://localhost:9001/project/new?from=github" + +def test_authorize(client, settings): + user = f.UserFactory.create() + client.login(user) + + authorize_url = reverse("importers-github-authorize") + + with mock.patch('taiga.importers.github.api.GithubImporter') as GithubImporterMock: + GithubImporterMock.get_access_token.return_value = "token" + response = client.post(authorize_url, content_type="application/json", data=json.dumps({"code": "code"})) + assert GithubImporterMock.get_access_token.calledWith( + settings.IMPORTERS['github']['client_id'], + settings.IMPORTERS['github']['client_secret'], + "code" + ) + + assert response.status_code == 200 + assert 'token' in response.data + assert response.data['token'] == "token" + +def test_authorize_without_code(client): + user = f.UserFactory.create() + client.login(user) + + authorize_url = reverse("importers-github-authorize") + + response = client.post(authorize_url, content_type="application/json", data=json.dumps({})) + + assert response.status_code == 400 + assert 'token' not in response.data + assert '_error_message' in response.data + assert response.data['_error_message'] == "Code param needed" + + +def test_authorize_with_bad_verify(client, settings): + user = f.UserFactory.create() + client.login(user) + + authorize_url = reverse("importers-github-authorize") + + with mock.patch('taiga.importers.github.api.GithubImporter') as GithubImporterMock: + GithubImporterMock.get_access_token.side_effect = exceptions.InvalidAuthResult() + response = client.post(authorize_url, content_type="application/json", data=json.dumps({"code": "bad"})) + assert GithubImporterMock.get_access_token.calledWith( + settings.IMPORTERS['github']['client_id'], + settings.IMPORTERS['github']['client_secret'], + "bad" + ) + + assert response.status_code == 400 + assert 'token' not in response.data + assert '_error_message' in response.data + assert response.data['_error_message'] == "Invalid auth data" + + +def test_import_github_list_users(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-github-list-users") + + with mock.patch('taiga.importers.github.api.GithubImporter') as GithubImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "username": "user1", "full_name": "user1", "detected_user": None}, + {"id": 2, "username": "user2", "full_name": "user2", "detected_user": None} + ] + GithubImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 200 + assert response.data[0]["id"] == 1 + assert response.data[1]["id"] == 2 + + +def test_import_github_list_users_without_project(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-github-list-users") + + with mock.patch('taiga.importers.github.api.GithubImporter') as GithubImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "username": "user1", "full_name": "user1", "detected_user": None}, + {"id": 2, "username": "user2", "full_name": "user2", "detected_user": None} + ] + GithubImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 400 + + +def test_import_github_list_users_with_problem_on_request(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-github-list-users") + + with mock.patch('taiga.importers.github.importer.GithubClient') as GithubClientMock: + instance = mock.Mock() + instance.get.side_effect = exc.WrongArguments("Invalid Request") + GithubClientMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 400 + + +def test_import_github_list_projects(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-github-list-projects") + + with mock.patch('taiga.importers.github.api.GithubImporter') as GithubImporterMock: + instance = mock.Mock() + instance.list_projects.return_value = ["project1", "project2"] + GithubImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 200 + assert response.data[0] == "project1" + assert response.data[1] == "project2" + + +def test_import_github_list_projects_with_problem_on_request(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-github-list-projects") + + with mock.patch('taiga.importers.github.importer.GithubClient') as GithubClientMock: + instance = mock.Mock() + instance.get.side_effect = exc.WrongArguments("Invalid Request") + GithubClientMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 400 + + +def test_import_github_project_without_project_id(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-github-import-project") + + with mock.patch('taiga.importers.github.tasks.GithubImporter') as GithubImporterMock: + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 400 + settings.CELERY_ENABLED = False + + +def test_import_github_project_with_celery_enabled(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + project = f.ProjectFactory.create(slug="async-imported-project") + client.login(user) + + url = reverse("importers-github-import-project") + + with mock.patch('taiga.importers.github.tasks.GithubImporter') as GithubImporterMock: + instance = mock.Mock() + instance.import_project.return_value = project + GithubImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 202 + assert "task_id" in response.data + settings.CELERY_ENABLED = False + + +def test_import_github_project_with_celery_disabled(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(slug="imported-project") + client.login(user) + + url = reverse("importers-github-import-project") + + with mock.patch('taiga.importers.github.api.GithubImporter') as GithubImporterMock: + instance = mock.Mock() + instance.import_project.return_value = project + GithubImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 200 + assert "slug" in response.data + assert response.data['slug'] == "imported-project" diff --git a/tests/integration/test_importers_jira_api.py b/tests/integration/test_importers_jira_api.py new file mode 100644 index 000000000..32313a3ea --- /dev/null +++ b/tests/integration/test_importers_jira_api.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json + +from unittest import mock + +from django.urls import reverse + +from .. import factories as f +from taiga.base.utils import json +from taiga.base import exceptions as exc +from taiga.users.models import AuthData + + +pytestmark = pytest.mark.django_db + + +fake_token = "access.secret" + + +def test_auth_url(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-auth-url")+"?url=http://jiraserver" + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporter: + JiraNormalImporter.get_auth_url.return_value = ("test_oauth_token", "test_oauth_secret", "http://jira-server-url") + response = client.get(url, content_type="application/json") + + auth_data = user.auth_data.get(key="jira-oauth") + assert auth_data.extra['oauth_token'] == "test_oauth_token" + assert auth_data.extra['oauth_secret'] == "test_oauth_secret" + assert auth_data.extra['url'] == "http://jiraserver" + + assert response.status_code == 200 + assert 'url' in response.data + assert response.data['url'] == "http://jira-server-url" + + +def test_authorize(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-auth-url") + authorize_url = reverse("importers-jira-authorize") + + AuthData.objects.get_or_create( + user=user, + key="jira-oauth", + value="", + extra={ + "oauth_token": "test-oauth-token", + "oauth_secret": "test-oauth-secret", + "url": "http://jiraserver", + } + ) + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporter: + JiraNormalImporter.get_access_token.return_value = { + "access_token": "test-access-token", + "access_token_secret": "test-access-token-secret" + } + response = client.post(authorize_url, content_type="application/json", data={}) + + assert response.status_code == 200 + assert 'token' in response.data + assert response.data['token'] == "test-access-token.test-access-token-secret" + assert 'url' in response.data + assert response.data['url'] == "http://jiraserver" + + +def test_authorize_without_token_and_secret(client): + user = f.UserFactory.create() + client.login(user) + + authorize_url = reverse("importers-jira-authorize") + AuthData.objects.filter(user=user, key="jira-oauth").delete() + + response = client.post(authorize_url, content_type="application/json", data={}) + + assert response.status_code == 400 + assert 'token' not in response.data + + +def test_import_jira_list_users(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-users") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "fullName": "user1", "email": None}, + {"id": 2, "fullName": "user2", "email": None} + ] + JiraNormalImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver", "project": 1})) + + assert response.status_code == 200 + assert response.data[0]["id"] == 1 + assert response.data[1]["id"] == 2 + + +def test_import_jira_list_users_without_project(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-users") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "fullName": "user1", "email": None}, + {"id": 2, "fullName": "user2", "email": None} + ] + JiraNormalImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver"})) + + assert response.status_code == 400 + + +def test_import_jira_list_users_with_problem_on_request(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-users") + + with mock.patch('taiga.importers.jira.common.JiraClient') as JiraClientMock: + instance = mock.Mock() + instance.get.side_effect = exc.WrongArguments("Invalid Request") + JiraClientMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver", "project": 1})) + + assert response.status_code == 400 + + +def test_import_jira_list_projects(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-projects") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporterMock: + with mock.patch('taiga.importers.jira.api.JiraAgileImporter') as JiraAgileImporterMock: + instance = mock.Mock() + instance.list_projects.return_value = [{"name": "project1"}, {"name": "project2"}] + JiraNormalImporterMock.return_value = instance + instance_agile = mock.Mock() + instance_agile.list_projects.return_value = [{"name": "agile1"}, {"name": "agile2"}] + JiraAgileImporterMock.return_value = instance_agile + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver"})) + + assert response.status_code == 200 + assert response.data[0] == {"name": "agile1"} + assert response.data[1] == {"name": "agile2"} + assert response.data[2] == {"name": "project1"} + assert response.data[3] == {"name": "project2"} + + +def test_import_jira_list_projects_with_problem_on_request(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-list-projects") + + with mock.patch('taiga.importers.jira.common.JiraClient') as JiraClientMock: + instance = mock.Mock() + instance.get.side_effect = exc.WrongArguments("Invalid Request") + JiraClientMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver"})) + + assert response.status_code == 400 + + +def test_import_jira_project_without_project_id(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-import-project") + + with mock.patch('taiga.importers.jira.tasks.JiraNormalImporter') as JiraNormalImporterMock: + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver"})) + + assert response.status_code == 400 + settings.CELERY_ENABLED = False + + +def test_import_jira_project_without_url(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-jira-import-project") + + with mock.patch('taiga.importers.jira.tasks.JiraNormalImporter') as JiraNormalImporterMock: + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "project_id": 1})) + + assert response.status_code == 400 + settings.CELERY_ENABLED = False + + +def test_import_jira_project_with_celery_enabled(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + project = f.ProjectFactory.create(slug="async-imported-project") + client.login(user) + + url = reverse("importers-jira-import-project") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as ApiJiraNormalImporterMock: + with mock.patch('taiga.importers.jira.tasks.JiraNormalImporter') as TasksJiraNormalImporterMock: + TasksJiraNormalImporterMock.return_value.import_project.return_value = project + ApiJiraNormalImporterMock.return_value.list_issue_types.return_value = [] + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver", "project": 1})) + + assert response.status_code == 202 + assert "task_id" in response.data + settings.CELERY_ENABLED = False + + +def test_import_jira_project_with_celery_disabled(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(slug="imported-project") + client.login(user) + + url = reverse("importers-jira-import-project") + + with mock.patch('taiga.importers.jira.api.JiraNormalImporter') as JiraNormalImporterMock: + instance = mock.Mock() + instance.import_project.return_value = project + instance.list_issue_types.return_value = [] + JiraNormalImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "access.secret", "url": "http://jiraserver", "project": 1})) + + assert response.status_code == 200 + assert "slug" in response.data + assert response.data['slug'] == "imported-project" diff --git a/tests/integration/test_importers_trello_api.py b/tests/integration/test_importers_trello_api.py new file mode 100644 index 000000000..b12b2baca --- /dev/null +++ b/tests/integration/test_importers_trello_api.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json + +from unittest import mock + +from django.urls import reverse + +from .. import factories as f +from taiga.base.utils import json +from taiga.base import exceptions as exc + +import taiga.importers.trello.importer + +pytestmark = pytest.mark.django_db + + +def test_auth_url(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-trello-auth-url") + + with mock.patch('taiga.importers.trello.importer.OAuth1Session') as OAuth1SessionMock: + session = mock.Mock() + session.fetch_request_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"} + OAuth1SessionMock.return_value = session + + response = client.get(url, content_type="application/json") + + assert response.status_code == 200 + assert 'url' in response.data + assert response.data['url'] == "https://trello.com/1/OAuthAuthorizeToken?oauth_token=token&scope=read,account&expiration=1day&name=Taiga&return_url=http://localhost:9001/project/new/import/trello" + + +def test_authorize(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-trello-auth-url") + authorize_url = reverse("importers-trello-authorize") + + with mock.patch('taiga.importers.trello.importer.OAuth1Session') as OAuth1SessionMock: + session = mock.Mock() + session.fetch_request_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"} + session.fetch_access_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"} + OAuth1SessionMock.return_value = session + + client.get(url, content_type="application/json") + response = client.post(authorize_url, content_type="application/json", data=json.dumps({"oauth_verifier": "token"})) + + assert response.status_code == 200 + assert 'token' in response.data + assert response.data['token'] == "token" + +def test_authorize_without_token_and_secret(client): + user = f.UserFactory.create() + client.login(user) + + authorize_url = reverse("importers-trello-authorize") + + with mock.patch('taiga.importers.trello.importer.OAuth1Session') as OAuth1SessionMock: + session = mock.Mock() + session.fetch_access_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"} + OAuth1SessionMock.return_value = session + + response = client.post(authorize_url, content_type="application/json", data=json.dumps({"oauth_verifier": "token"})) + + assert response.status_code == 400 + assert 'token' not in response.data + + +def test_authorize_with_bad_verify(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-trello-auth-url") + authorize_url = reverse("importers-trello-authorize") + + with mock.patch('taiga.importers.trello.importer.OAuth1Session') as OAuth1SessionMock: + session = mock.Mock() + session.fetch_request_token.return_value = {"oauth_token": "token", "oauth_token_secret": "token"} + session.fetch_access_token.side_effect = Exception("Bad Token") + OAuth1SessionMock.return_value = session + + client.get(url, content_type="application/json") + response = client.post(authorize_url, content_type="application/json", data=json.dumps({"oauth_verifier": "token"})) + + assert response.status_code == 400 + assert 'token' not in response.data + + +def test_import_trello_list_users(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-trello-list-users") + + with mock.patch('taiga.importers.trello.api.TrelloImporter') as TrelloImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "fullName": "user1", "email": None}, + {"id": 2, "fullName": "user2", "email": None} + ] + TrelloImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 200 + assert response.data[0]["id"] == 1 + assert response.data[1]["id"] == 2 + + +def test_import_trello_list_users_without_project(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-trello-list-users") + + with mock.patch('taiga.importers.trello.api.TrelloImporter') as TrelloImporterMock: + instance = mock.Mock() + instance.list_users.return_value = [ + {"id": 1, "fullName": "user1", "email": None}, + {"id": 2, "fullName": "user2", "email": None} + ] + TrelloImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 400 + + +def test_import_trello_list_users_with_problem_on_request(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-trello-list-users") + + with mock.patch('taiga.importers.trello.importer.TrelloClient') as TrelloClientMock: + instance = mock.Mock() + instance.get.side_effect = exc.WrongArguments("Invalid Request") + TrelloClientMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 400 + + +def test_import_trello_list_projects(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-trello-list-projects") + + with mock.patch('taiga.importers.trello.api.TrelloImporter') as TrelloImporterMock: + instance = mock.Mock() + instance.list_projects.return_value = ["project1", "project2"] + TrelloImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 200 + assert response.data[0] == "project1" + assert response.data[1] == "project2" + + +def test_import_trello_list_projects_with_problem_on_request(client, settings): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-trello-list-projects") + + with mock.patch('taiga.importers.trello.importer.TrelloClient') as TrelloClientMock: + instance = mock.Mock() + instance.get.side_effect = exc.WrongArguments("Invalid Request") + TrelloClientMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 400 + + +def test_import_trello_project_without_project_id(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importers-trello-import-project") + + with mock.patch('taiga.importers.trello.tasks.TrelloImporter') as TrelloImporterMock: + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token"})) + + assert response.status_code == 400 + settings.CELERY_ENABLED = False + + +def test_import_trello_transform_action_data_wrong_id_old(client, settings): + user = f.UserFactory.create() + client.login(user) + + us = f.UserStoryFactory.create() + f.UserStoryStatusFactory.create(name="completed", project=us.project) + f.UserStoryStatusFactory.create(name="pending", project=us.project) + action = { + "data": {"old": {"idList": "notExists"}, "card": {"idList": "notExists"}}, + "type": "updateCard", + } + statuses = {"1": {"name": "completed"}, "2": {"name": "pending"}} + + with mock.patch("taiga.importers.trello.importer.TrelloClient") as TrelloClientMock: + TrelloClientMock.get.return_value = {"id": 1234, "name": "Not exists board"} + trello_importer = taiga.importers.trello.importer.TrelloImporter(user, "token") + + assert ( + trello_importer._transform_action_data( + us, action=action, statuses=statuses, options={} + ) + is None + ) + + +def test_import_trello_project_with_celery_enabled(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + project = f.ProjectFactory.create(slug="async-imported-project") + client.login(user) + + url = reverse("importers-trello-import-project") + + with mock.patch('taiga.importers.trello.tasks.TrelloImporter') as TrelloImporterMock: + instance = mock.Mock() + instance.import_project.return_value = project + TrelloImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 202 + assert "task_id" in response.data + settings.CELERY_ENABLED = False + + +def test_import_trello_project_with_celery_disabled(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(slug="imported-project") + client.login(user) + + url = reverse("importers-trello-import-project") + + with mock.patch('taiga.importers.trello.api.TrelloImporter') as TrelloImporterMock: + instance = mock.Mock() + instance.import_project.return_value = project + TrelloImporterMock.return_value = instance + response = client.post(url, content_type="application/json", data=json.dumps({"token": "token", "project": 1})) + + assert response.status_code == 200 + assert "slug" in response.data + assert response.data['slug'] == "imported-project" diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py new file mode 100644 index 000000000..050fb56ff --- /dev/null +++ b/tests/integration/test_issues.py @@ -0,0 +1,953 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid +import csv + +from datetime import timedelta +from urllib.parse import quote + +from unittest import mock + +from django.urls import reverse +from django.utils import timezone + +from taiga.base.utils import json +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.issues import services +from taiga.projects.userstories.models import UserStory +from taiga.projects.occ import OCCResourceMixin + +from .. import factories as f + +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 +Issue #2 +""" + issues = services.get_issues_from_bulk(data) + + assert len(issues) == 2 + assert issues[0].subject == "Issue #1" + assert issues[1].subject == "Issue #2" + + +def test_create_issues_in_bulk(db): + data = """ +Issue #1 +Issue #2 +""" + + with mock.patch("taiga.projects.issues.services.db") as db: + issues = services.create_issues_in_bulk(data) + db.save_in_bulk.assert_called_once_with(issues, None, None) + + +def test_create_issue_without_status(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + status = f.IssueStatusFactory.create(project=project) + priority = f.PriorityFactory.create(project=project) + severity = f.SeverityFactory.create(project=project) + type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = status + project.default_priority = priority + project.default_severity = severity + project.default_issue_type = type + project.save() + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("issues-list") + + data = {"subject": "Test user story", "project": project.id} + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data['status'] == project.default_issue_status.id + assert response.data['severity'] == project.default_severity.id + assert response.data['priority'] == project.default_priority.id + assert response.data['type'] == project.default_issue_type.id + + +def test_create_issue_without_status_in_project_without_default_values(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, + default_issue_status=None, + default_priority=None, + default_severity=None, + default_issue_type = None) + + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("issues-list") + + data = {"subject": "Test user story", "project": project.id} + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data['status'] == None + assert response.data['severity'] == None + assert response.data['priority'] == None + assert response.data['type'] == None + + +def test_api_create_issues_in_bulk(client): + project = f.create_project() + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("issues-bulk-create") + + data = {"bulk_issues": "Issue #1\nIssue #2\n", + "project_id": project.id} + + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200, response.data + + +def test_api_filter_by_subject(client): + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="some random subject", owner=user) + url = reverse("issues-list") + "?q=some subject" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1, number_of_issues + + +def test_api_filter_by_text_1(client): + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="this is the issue one", owner=user) + f.create_issue(subject="this is the issue two", owner=issue.owner) + url = reverse("issues-list") + "?q=one" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + + +def test_api_filter_by_text_2(client): + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="this is the issue one", owner=user) + f.create_issue(subject="this is the issue two", owner=issue.owner) + url = reverse("issues-list") + "?q=this is the issue one" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + + +def test_api_filter_by_text_3(client): + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="this is the issue one", owner=user) + f.create_issue(subject="this is the issue two", owner=issue.owner) + url = reverse("issues-list") + "?q=this is the issue" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 2 + + +def test_api_filter_by_text_4(client): + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="this is the issue one", owner=user) + f.create_issue(subject="this is the issue two", owner=issue.owner) + url = reverse("issues-list") + "?q=one two" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 0 + + +def test_api_filter_by_text_5(client): + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="python 3", owner=user) + url = reverse("issues-list") + "?q=python 3" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + + +def test_api_filter_by_text_6(client): + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="test", owner=user) + issue.ref = 123 + issue.save() + url = reverse("issues-list") + "?q=%s" % (issue.ref) + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + + +def test_api_filter_by_created_date(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_issue = f.create_issue(owner=user, created_date=one_day_ago) + issue = f.create_issue(owner=user) + + url = reverse("issues-list") + "?created_date=%s" % ( + quote(issue.created_date.isoformat()) + ) + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + assert response.data[0]["ref"] == issue.ref + + +def test_api_filter_by_created_date__gt(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_issue = f.create_issue(owner=user, created_date=one_day_ago) + issue = f.create_issue(owner=user) + + url = reverse("issues-list") + "?created_date__gt=%s" % ( + quote(one_day_ago.isoformat()) + ) + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + assert response.data[0]["ref"] == issue.ref + + +def test_api_filter_by_created_date__gte(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_issue = f.create_issue(owner=user, created_date=one_day_ago) + issue = f.create_issue(owner=user) + + url = reverse("issues-list") + "?created_date__gte=%s" % ( + quote(one_day_ago.isoformat()) + ) + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 2 + + +def test_api_filter_by_created_date__lt(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_issue = f.create_issue(owner=user, created_date=one_day_ago) + issue = f.create_issue(owner=user) + + url = reverse("issues-list") + "?created_date__lt=%s" % ( + quote(issue.created_date.isoformat()) + ) + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert response.data[0]["ref"] == old_issue.ref + + +def test_api_filter_by_created_date__lte(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_issue = f.create_issue(owner=user, created_date=one_day_ago) + issue = f.create_issue(owner=user) + + url = reverse("issues-list") + "?created_date__lte=%s" % ( + quote(issue.created_date.isoformat()) + ) + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 2 + + +def test_api_filter_by_modified_date__gte(client): + user = f.UserFactory(is_superuser=True) + _day_ago = timezone.now() - timedelta(days=1) + + older_issue = f.create_issue(owner=user) + issue = f.create_issue(owner=user) + # we have to refresh as it slightly differs + issue.refresh_from_db() + + assert older_issue.modified_date < issue.modified_date + + url = reverse("issues-list") + "?modified_date__gte=%s" % ( + quote(issue.modified_date.isoformat()) + ) + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + assert response.data[0]["ref"] == issue.ref + + +def test_api_filter_by_finished_date(client): + user = f.UserFactory(is_superuser=True) + project = f.ProjectFactory.create() + status0 = f.IssueStatusFactory.create(project=project, is_closed=True) + + issue = f.create_issue(owner=user) + finished_issue = f.create_issue(owner=user, status=status0) + + assert finished_issue.finished_date + + url = reverse("issues-list") + "?finished_date__gte=%s" % ( + quote(finished_issue.finished_date.isoformat()) + ) + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + 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_tags_or_operator(client): + data = create_filter_issues_context() + project = data["project"] + client.login(data["users"][0]) + tags = data["tags"] + + url = "{}?project={}&tags={},{}".format(reverse('issues-list'), project.id, tags[0], tags[2]) + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 5 + + +def test_api_filters_data(client): + 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 + response = client.get(url) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 3 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4 + + assert next(filter(lambda i: i['id'] == type1.id, response.data["types"]))["count"] == 5 + assert next(filter(lambda i: i['id'] == type2.id, response.data["types"]))["count"] == 5 + + assert next(filter(lambda i: i['id'] == priority0.id, response.data["priorities"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == priority1.id, response.data["priorities"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == priority2.id, response.data["priorities"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == priority3.id, response.data["priorities"]))["count"] == 3 + + assert next(filter(lambda i: i['id'] == severity0.id, response.data["severities"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == severity1.id, response.data["severities"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == severity2.id, response.data["severities"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == severity3.id, response.data["severities"]))["count"] == 2 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 1 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 5 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 4 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 4 + + ## Filter ((status0 or status3) and type1) + response = client.get(url + "&status={},{}&type={}".format(status3.id, status0.id, type1.id)) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 3 + + assert next(filter(lambda i: i['id'] == type1.id, response.data["types"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == type2.id, response.data["types"]))["count"] == 3 + + assert next(filter(lambda i: i['id'] == priority0.id, response.data["priorities"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == priority1.id, response.data["priorities"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == priority2.id, response.data["priorities"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == priority3.id, response.data["priorities"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == severity0.id, response.data["severities"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == severity1.id, response.data["severities"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == severity2.id, response.data["severities"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == severity3.id, response.data["severities"]))["count"] == 1 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1 + + ## Filter ((tag1 and tag2) and (user1 or user2)) + response = client.get(url + "&tags={},{}&owner={},{}".format(tag1, tag2, user1.id, user2.id)) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 2 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 3 + + assert next(filter(lambda i: i['id'] == type1.id, response.data["types"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == type2.id, response.data["types"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] == priority0.id, response.data["priorities"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == priority1.id, response.data["priorities"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == priority2.id, response.data["priorities"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == priority3.id, response.data["priorities"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == severity0.id, response.data["severities"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == severity1.id, response.data["severities"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == severity2.id, response.data["severities"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == severity3.id, response.data["severities"]))["count"] == 1 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 1 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 3 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3 + + +def test_get_invalid_csv(client): + url = reverse("issues-csv") + project = f.ProjectFactory.create(issues_csv_uuid=uuid.uuid4().hex) + client.login(project.owner) + + response = client.get(url) + assert response.status_code == 404 + + response = client.get("{}?uuid={}".format(url, "not-valid-uuid")) + assert response.status_code == 404 + + +def test_get_valid_csv(client): + url = reverse("issues-csv") + project = f.ProjectFactory.create(issues_csv_uuid=uuid.uuid4().hex) + + response = client.get("{}?uuid={}".format(url, project.issues_csv_uuid)) + assert response.status_code == 200 + + +def test_custom_fields_csv_generation(): + project = f.ProjectFactory.create(issues_csv_uuid=uuid.uuid4().hex) + attr = f.IssueCustomAttributeFactory.create(project=project, name="attr1", description="desc") + issue = f.IssueFactory.create(project=project) + attr_values = issue.custom_attributes_values + attr_values.attributes_values = {str(attr.id):"val1"} + attr_values.save() + queryset = project.issues.all() + data = services.issues_to_csv(project, queryset) + data.seek(0) + reader = csv.reader(data) + row = next(reader) + + assert row[27] == attr.name + row = next(reader) + assert row[27] == "val1" + + +def test_api_validator_assigned_to_when_update_issues(client): + project = f.create_project(anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + project_member_owner = f.MembershipFactory.create(project=project, + user=project.owner, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_member = f.MembershipFactory.create(project=project, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_no_member = f.MembershipFactory.create(is_admin=True) + + issue = f.create_issue(project=project, owner=project.owner) + + url = reverse('issues-detail', kwargs={"pk": issue.pk}) + + # assign + data = { + "assigned_to": project_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == project_member.user.id + + # unassign + data = { + "assigned_to": None, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == None + + # assign to invalid user + data = { + "assigned_to": project_no_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + + +def test_api_validator_assigned_to_when_create_issues(client): + project = f.create_project(anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + project_member_owner = f.MembershipFactory.create(project=project, + user=project.owner, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_member = f.MembershipFactory.create(project=project, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_no_member = f.MembershipFactory.create(is_admin=True) + + url = reverse('issues-list') + + # assign + data = { + "subject": "test", + "project": project.id, + "assigned_to": project_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == project_member.user.id + + # unassign + data = { + "subject": "test", + "project": project.id, + "assigned_to": None, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == None + + # assign to invalid user + data = { + "subject": "test", + "project": project.id, + "assigned_to": project_no_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + + +def test_create_issue_in_milestone(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, default_task_status=None) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + milestone = f.MilestoneFactory(project=project) + project.save() + + url = reverse("issues-list") + + data = {"subject": "Test issue with milestone", "project": project.id, + "milestone": milestone.id} + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data['status'] == project.default_issue_status.id + assert response.data['project'] == project.id + assert response.data['milestone'] == milestone.id + + +def test_api_create_in_bulk_with_status_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) + + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.save() + milestone = f.MilestoneFactory(project=project) + + url = reverse("issues-bulk-create") + data = { + "bulk_issues": "Issue #1\nIssue #2", + "project_id": project.id, + "milestone_id": milestone.id, + "status_id": project.default_issue_status.id + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert response.data[0]["status"] == project.default_issue_status.id + 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) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + f.IssueFactory.create(project=project) + url = reverse("issues-list") + + client.login(project.owner) + + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0].get("milestone") + + +def test_get_issues_in_milestone(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + project.save() + milestone = f.MilestoneFactory(project=project) + f.IssueFactory.create(project=project, milestone=milestone) + f.IssueFactory.create(project=project) + url = reverse("issues-list") + "?milestone={}".format(milestone.id) + + client.login(project.owner) + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0].get("milestone") == milestone.id + + +def test_promote_issue_to_us(client): + user_1 = f.UserFactory.create() + user_2 = f.UserFactory.create() + watching_user = f.UserFactory() + project = f.ProjectFactory.create(owner=user_1) + f.MembershipFactory.create(project=project, user=user_1, is_admin=True) + f.MembershipFactory.create(project=project, user=user_2, is_admin=False) + issue = f.IssueFactory.create(project=project, owner=user_1, assigned_to=user_2) + issue.add_watcher(watching_user) + + f.IssueAttachmentFactory(project=project, content_object=issue, owner=user_1) + f.IssueAttachmentFactory(project=project, content_object=issue, owner=user_1) + + f.HistoryEntryFactory.create( + project=project, + user={"pk": user_1.id}, + comment="Test comment", + key="issues.issue:{}".format(issue.id), + is_hidden=False, + diff=[] + ) + + client.login(user_1) + + url = reverse('issues-promote-to-user-story', kwargs={"pk": issue.pk}) + data = {"project_id": project.id} + promote_response = client.json.post(url, json.dumps(data)) + + us_ref = promote_response.data.pop() + us = UserStory.objects.get(ref=us_ref) + us_response = client.get(reverse("userstories-detail", args=[us.pk]), + {"include_attachments": True}) + + assert promote_response.status_code == 200, promote_response.data + assert us_response.data["subject"] == issue.subject + assert us_response.data["description"] == issue.description + assert us_response.data["owner"] == issue.owner_id + assert us_response.data["generated_from_issue"] == issue.id + assert us_response.data["assigned_users"] == {user_2.id} + assert us_response.data["total_watchers"] == 1 + assert us_response.data["total_attachments"] == 2 + assert us_response.data["total_comments"] == 1 diff --git a/tests/integration/test_issues_tags.py b/tests/integration/test_issues_tags.py new file mode 100644 index 000000000..d5076ee1b --- /dev/null +++ b/tests/integration/test_issues_tags.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from unittest import mock +from collections import OrderedDict + +from django.urls import reverse + +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +def test_api_issue_add_new_tags_with_error(client): + project = f.ProjectFactory.create() + issue = f.create_issue(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=issue.owner, is_admin=True) + url = reverse("issues-detail", kwargs={"pk": issue.pk}) + data = { + "tags": [], + "version": issue.version + } + + client.login(issue.owner) + + data["tags"] = [1] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [["back"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [["back", "#cccc"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [[1, "#ccc"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + +def test_api_issue_add_new_tags_without_colors(client): + project = f.ProjectFactory.create() + issue = f.create_issue(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=issue.owner, is_admin=True) + url = reverse("issues-detail", kwargs={"pk": issue.pk}) + data = { + "tags": [ + ["back", None], + ["front", None], + ["ux", None] + ], + "version": issue.version + } + + client.login(issue.owner) + + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200, response.data + + tags_colors = OrderedDict(project.tags_colors) + assert not tags_colors.keys() + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors + + +def test_api_issue_add_new_tags_with_colors(client): + project = f.ProjectFactory.create() + issue = f.create_issue(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=issue.owner, is_admin=True) + url = reverse("issues-detail", kwargs={"pk": issue.pk}) + data = { + "tags": [ + ["back", "#fff8e7"], + ["front", None], + ["ux", "#fabada"] + ], + "version": issue.version + } + + client.login(issue.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + + tags_colors = OrderedDict(project.tags_colors) + assert not tags_colors.keys() + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors + assert tags_colors["back"] == "#fff8e7" + assert tags_colors["ux"] == "#fabada" + + +def test_api_create_new_issue_with_tags(client): + project = f.ProjectFactory.create(tags_colors=[["front", "#aaaaaa"], ["ux", "#fabada"]]) + status = f.IssueStatusFactory.create(project=project) + project.default_issue_status = status + project.save() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("issues-list") + + data = { + "subject": "Test user story", + "project": project.id, + "tags": [ + ["back", "#fff8e7"], + ["front", None], + ["ux", "#fabada"] + ] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + + issue_tags_colors = OrderedDict(response.data["tags"]) + + assert issue_tags_colors["back"] == "#fff8e7" + assert issue_tags_colors["front"] == "#aaaaaa" + assert issue_tags_colors["ux"] == "#fabada" + + tags_colors = OrderedDict(project.tags_colors) + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert tags_colors["back"] == "#fff8e7" + assert tags_colors["ux"] == "#fabada" + assert tags_colors["front"] == "#aaaaaa" diff --git a/tests/integration/test_mdrender.py b/tests/integration/test_mdrender.py new file mode 100644 index 000000000..75df7dbfa --- /dev/null +++ b/tests/integration/test_mdrender.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from taiga.mdrender.service import render, render_and_extract + +from unittest.mock import MagicMock + +from .. import factories + +pytestmark = pytest.mark.django_db + +dummy_project = MagicMock() +dummy_project.id = 1 +dummy_project.slug = "test" + +def test_proccessor_valid_user_mention(): + user=factories.UserFactory(username="user1", full_name="test name") + project=factories.ProjectFactory() + factories.MembershipFactory(user=user, project=project) + result = render(project, "**@user1**") + expected_result = "

@user1

" + assert result == expected_result + + +def test_proccessor_valid_user_mention_with_dashes(): + user=factories.UserFactory(username="user1_text_after_dash", full_name="test name") + project=factories.ProjectFactory() + factories.MembershipFactory(user=user, project=project) + result = render(project, "**@user1_text_after_dash**") + expected_result = "

@user1_text_after_dash

" + assert result == expected_result + + +def test_proccessor_invalid_user_mention(): + result = render(dummy_project, "**@notvaliduser**") + assert result == '

@notvaliduser

' + + +def test_render_and_extract_mentions(): + user=factories.UserFactory(username="user1", full_name="test name") + project=factories.ProjectFactory() + factories.MembershipFactory(user=user, project=project) + (_, extracted) = render_and_extract(project, "**@user1**") + assert extracted['mentions'] == [user] + +def test_render_and_extract_mentions_with_capitalized_username(): + user=factories.UserFactory(username="User1", full_name="test") + project=factories.ProjectFactory() + factories.MembershipFactory(user=user, project=project) + (_, extracted) = render_and_extract(project, "**@User1**") + assert extracted['mentions'] == [user] + + +def test_proccessor_valid_email(): + result = render(dummy_project, "**beta.tester@taiga.io**") + expected_result = "

beta.tester@taiga.io

" + assert result == expected_result diff --git a/tests/integration/test_memberships.py b/tests/integration/test_memberships.py new file mode 100644 index 000000000..fa66220c1 --- /dev/null +++ b/tests/integration/test_memberships.py @@ -0,0 +1,907 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from unittest import mock +from django.urls import reverse + +from taiga.projects import services +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +def test_get_members_from_bulk(): + data = [{"role_id": "1", "username": "member1@email.com"}, + {"role_id": "1", "username": "member2@email.com"}] + members = services.get_members_from_bulk(data, project_id=1) + + assert len(members) == 2 + assert members[0].email == "member1@email.com" + assert members[1].email == "member2@email.com" + + +def test_create_member_forbidden_for_unverified_user(client): + project = f.ProjectFactory() + john = f.UserFactory.create(verified_email=False) + joseph = f.UserFactory.create() + tester = f.RoleFactory(project=project, name="Tester") + f.MembershipFactory(project=project, user=john, is_admin=True) + url = reverse("memberships-list") + + data = {"project": project.id, "role": tester.pk, "username": joseph.email} + client.login(john) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + + +def test_create_member_forbidden_for_unverified_user_in_bulk(client): + project = f.ProjectFactory() + john = f.UserFactory.create(verified_email=False) + joseph = f.UserFactory.create() + tester = f.RoleFactory(project=project, name="Tester") + f.MembershipFactory(project=project, user=john, is_admin=True) + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships":[{"role_id": tester.pk, "username": joseph.email}] + } + client.login(john) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + + +def test_create_member_using_email(client): + project = f.ProjectFactory() + john = f.UserFactory.create() + joseph = f.UserFactory.create() + tester = f.RoleFactory(project=project, name="Tester") + f.MembershipFactory(project=project, user=john, is_admin=True) + url = reverse("memberships-list") + + data = {"project": project.id, "role": tester.pk, "username": joseph.email} + client.login(john) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert response.data["email"] == joseph.email + + +def test_create_member_using_username_without_being_contacts(client): + project = f.ProjectFactory() + john = f.UserFactory.create() + joseph = f.UserFactory.create() + tester = f.RoleFactory(project=project, name="Tester") + f.MembershipFactory(project=project, user=john, is_admin=True) + url = reverse("memberships-list") + + data = {"project": project.id, "role": tester.pk, "username": joseph.username} + client.login(john) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "The user must be a valid contact" in response.data["username"][0] + + +def test_create_member_using_username_being_contacts(client): + project = f.ProjectFactory() + john = f.UserFactory.create() + joseph = f.UserFactory.create() + tester = f.RoleFactory(project=project, name="Tester", permissions=["view_project"]) + f.MembershipFactory(project=project, user=john, role=tester, is_admin=True) + + # They are members from another project + project2 = f.ProjectFactory() + gamer = f.RoleFactory(project=project2, name="Gamer", permissions=["view_project"]) + f.MembershipFactory(project=project2, user=john, role=gamer, is_admin=True) + f.MembershipFactory(project=project2, user=joseph, role=gamer) + + url = reverse("memberships-list") + + data = {"project": project.id, "role": tester.pk, "username": joseph.username} + client.login(john) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert response.data["user"] == joseph.id + + +def test_create_members_in_bulk(): + with mock.patch("taiga.projects.services.members.db") as db: + data = [{"role_id": "1", "username": "member1@email.com"}, + {"role_id": "1", "username": "member2@email.com"}] + members = services.get_members_from_bulk(data, project_id=1) + services.create_members_in_bulk(members) + db.save_in_bulk.assert_called_once_with(members, None, None) + + +def test_api_create_bulk_members(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) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + response_user_ids = set([u["user"] for u in response.data]) + user_ids = {other.id, joseph.id} + assert(user_ids.issubset(response_user_ids)) + + +def test_api_create_bulk_members_invalid_user_id(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) + + 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) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "bulk_memberships" in response.data + assert "username" in response.data["bulk_memberships"][1] + + +def test_api_create_bulk_members_with_invalid_roles(client): + project = f.ProjectFactory() + john = f.UserFactory.create() + joseph = f.UserFactory.create() + tester = f.RoleFactory(name="Tester") + gamer = f.RoleFactory(name="Gamer") + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": tester.pk, "username": john.email}, + {"role_id": gamer.pk, "username": joseph.email}, + ] + } + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "bulk_memberships" in response.data + + +def test_api_create_bulk_members_with_allowed_domain(client): + project = f.ProjectFactory() + john = f.UserFactory.create() + joseph = f.UserFactory.create() + tester = f.RoleFactory(project=project, name="Tester") + gamer = f.RoleFactory(project=project, name="Gamer") + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": tester.pk, "username": "test1@email.com"}, + {"role_id": gamer.pk, "username": "test2@email.com"}, + ] + } + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert response.data[0]["email"] == "test1@email.com" + assert response.data[1]["email"] == "test2@email.com" + + +def test_api_create_bulk_members_with_allowed_and_unallowed_domain(client, settings): + project = f.ProjectFactory() + settings.USER_EMAIL_ALLOWED_DOMAINS = ['email.com'] + tester = f.RoleFactory(project=project, name="Tester") + gamer = f.RoleFactory(project=project, name="Gamer") + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": tester.pk, "username": "test@invalid-domain.com"}, + {"role_id": gamer.pk, "username": "test@email.com"}, + ] + } + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "username" in response.data["bulk_memberships"][0] + assert "username" not in response.data["bulk_memberships"][1] + + +def test_api_create_bulk_members_with_unallowed_domains(client, settings): + project = f.ProjectFactory() + settings.USER_EMAIL_ALLOWED_DOMAINS = ['email.com'] + tester = f.RoleFactory(project=project, name="Tester") + gamer = f.RoleFactory(project=project, name="Gamer") + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": tester.pk, "username": "test1@invalid-domain.com"}, + {"role_id": gamer.pk, "username": "test2@invalid-domain.com"}, + ] + } + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "username" in response.data["bulk_memberships"][0] + assert "username" in response.data["bulk_memberships"][1] + +######## +# Number of member and project restriction +######## + +# Private +def test_api_create_bulk_members_without_enough_memberships_private_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_memberships_private_projects=4) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + other_project = f.ProjectFactory(owner=user, is_private=True) + f.MembershipFactory(project=other_project, user=user, is_admin=True) + f.MembershipFactory(project=other_project) + f.MembershipFactory(project=other_project) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "username": "test1@test.com"}, + {"role_id": role.pk, "username": "test2@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for private" in response.data["_error_message"] + + +def test_api_create_bulk_members_without_enough_memberships_private_project_slots_one_project(client): + user = f.UserFactory.create(max_memberships_private_projects=2) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + project2 = f.ProjectFactory(owner=user, is_private=True) + f.MembershipFactory(project=project2) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "username": "test1@test.com"}, + {"role_id": role.pk, "username": "test2@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for private" in response.data["_error_message"] + + +def test_api_create_bulk_members_for_admin_without_enough_memberships_private_project_slots_one_project(client): + owner = f.UserFactory.create(max_memberships_private_projects=3) + user = f.UserFactory.create() + project = f.ProjectFactory(owner=owner, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=owner, is_admin=True) + f.MembershipFactory(project=project, user=user, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "username": "test1@test.com"}, + {"role_id": role.pk, "username": "test4@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for private" in response.data["_error_message"] + + +def test_api_create_bulk_members_with_enough_memberships_private_project_slots_one_project(client): + user = f.UserFactory.create(max_memberships_private_projects=3) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "username": "test1@test.com"}, + {"role_id": role.pk, "username": "test2@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + +# Public + +def test_api_create_bulk_members_without_enough_memberships_public_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_memberships_public_projects=4) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + other_project = f.ProjectFactory(owner=user, is_private=False) + f.MembershipFactory(project=other_project, user=user, is_admin=True) + f.MembershipFactory(project=other_project) + f.MembershipFactory(project=other_project) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "username": "test1@test.com"}, + {"role_id": role.pk, "username": "test2@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for public" in response.data["_error_message"] + + +def test_api_create_bulk_members_without_enough_memberships_public_project_slots_one_project(client): + user = f.UserFactory.create(max_memberships_public_projects=2) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "username": "test1@test.com"}, + {"role_id": role.pk, "username": "test2@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for public" in response.data["_error_message"] + + +def test_api_create_bulk_members_for_admin_without_enough_memberships_public_project_slots_one_project(client): + owner = f.UserFactory.create(max_memberships_public_projects=3) + user = f.UserFactory.create() + project = f.ProjectFactory(owner=owner, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=owner, is_admin=True) + f.MembershipFactory(project=project, user=user, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "username": "test1@test.com"}, + {"role_id": role.pk, "username": "test4@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for public" in response.data["_error_message"] + + +def test_api_create_bulk_members_with_enough_memberships_public_project_slots_one_project(client): + user = f.UserFactory.create(max_memberships_public_projects=3) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "username": "test1@test.com"}, + {"role_id": role.pk, "username": "test2@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + + +def test_api_create_bulk_members_with_extra_text(client, outbox): + project = f.ProjectFactory() + tester = f.RoleFactory(project=project, name="Tester") + f.MembershipFactory(project=project, user=project.owner, is_admin=True) + url = reverse("memberships-bulk-create") + + invitation_extra_text = "this is a not so random invitation text" + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": tester.pk, "username": "john@email.com"}, + ], + "invitation_extra_text": invitation_extra_text + } + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert response.data[0]["email"] == "john@email.com" + + message = outbox[0] + assert len(outbox) == 1 + assert message.to == ["john@email.com"] + assert "this is a not so random invitation text" in message.body + + +def test_api_resend_invitation(client, outbox): + invitation = f.create_invitation(user=None) + f.MembershipFactory(project=invitation.project, user=invitation.project.owner, is_admin=True) + url = reverse("memberships-resend-invitation", kwargs={"pk": invitation.pk}) + + client.login(invitation.project.owner) + response = client.post(url) + + assert response.status_code == 204 + assert len(outbox) == 1 + assert outbox[0].to == [invitation.email] + + +def test_api_invite_existing_user(client, outbox): + "Should create the invitation linked to that user" + user = f.UserFactory.create() + role = f.RoleFactory.create() + f.MembershipFactory(project=role.project, user=role.project.owner, is_admin=True) + + client.login(role.project.owner) + + url = reverse("memberships-list") + data = {"role": role.pk, "project": role.project.pk, "username": user.email} + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201, response.data + assert len(outbox) == 1 + assert user.memberships.count() == 1 + + message = outbox[0] + + assert message.to == [user.email] + assert "Added to the project" in message.subject + + +def test_api_create_invalid_membership_no_email_no_user(client): + "Should not create the invitation linked to that user" + user = f.UserFactory.create() + role = f.RoleFactory.create() + client.login(role.project.owner) + + url = reverse("memberships-list") + data = {"role": role.pk, "project": role.project.pk} + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400, response.data + assert user.memberships.count() == 0 + + +def test_api_create_invalid_membership_role_doesnt_exist_in_the_project(client): + "Should not create the invitation linked to that user" + user = f.UserFactory.create() + role = f.RoleFactory.create() + project = f.ProjectFactory.create() + + client.login(project.owner) + + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "username": user.email} + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400, response.data + assert response.data["role"][0] == "Invalid role for the project" + assert user.memberships.count() == 0 + + +def test_api_create_membership(client): + membership = f.MembershipFactory(is_admin=True) + role = f.RoleFactory.create(project=membership.project) + user = f.UserFactory.create() + + client.login(membership.user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": role.project.pk, "username": user.email} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert response.data["user_email"] == user.email + + +def test_api_create_membership_with_unallowed_domain(client, settings): + settings.USER_EMAIL_ALLOWED_DOMAINS = ['email.com'] + + membership = f.MembershipFactory(is_admin=True) + role = f.RoleFactory.create(project=membership.project) + + client.login(membership.user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": role.project.pk, "username": "test@invalid-email.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "username" in response.data + + +def test_api_create_membership_with_allowed_domain(client, settings): + settings.USER_EMAIL_ALLOWED_DOMAINS = ['email.com'] + + membership = f.MembershipFactory(is_admin=True) + role = f.RoleFactory.create(project=membership.project) + + client.login(membership.user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": role.project.pk, "username": "test@email.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert response.data["email"] == "test@email.com" + + +def test_api_create_membership_without_enough_memberships_private_project_slots_one_projects(client): + user = f.UserFactory.create(max_memberships_private_projects=1) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "username": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for private" in response.data["_error_message"] + + +def test_api_create_membership_without_enough_memberships_private_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_memberships_private_projects=3) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + other_project = f.ProjectFactory(owner=user, is_private=True) + f.MembershipFactory(project=other_project, user=user, is_admin=True) + f.MembershipFactory(project=other_project) + f.MembershipFactory(project=other_project) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "username": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for private" in response.data["_error_message"] + + +def test_api_create_membership_with_enough_memberships_private_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_memberships_private_projects=5) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + other_project = f.ProjectFactory(owner=user) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "username": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + +def test_api_create_membership_without_enough_memberships_public_project_slots_one_projects(client): + user = f.UserFactory.create(max_memberships_public_projects=1) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "username": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for public" in response.data["_error_message"] + + +def test_api_create_membership_without_enough_memberships_public_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_memberships_public_projects=3) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + other_project = f.ProjectFactory(owner=user, is_private=False) + f.MembershipFactory(project=other_project, user=user, is_admin=True) + f.MembershipFactory(project=other_project) + f.MembershipFactory(project=other_project) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "username": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached your current limit of memberships for public" in response.data["_error_message"] + + +def test_api_create_membership_with_enough_memberships_public_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_memberships_public_projects=5) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_admin=True) + + other_project = f.ProjectFactory(owner=user) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "username": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + +def test_api_edit_membership(client): + membership = f.MembershipFactory(is_admin=True) + client.login(membership.user) + url = reverse("memberships-detail", args=[membership.id]) + data = {"username": "new@email.com"} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + + +def test_api_edit_membership(client): + membership = f.MembershipFactory(is_admin=True) + client.login(membership.user) + url = reverse("memberships-detail", args=[membership.id]) + data = {"username": "new@email.com"} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + + +def test_api_change_owner_membership_to_no_admin_return_error(client): + project = f.ProjectFactory() + membership_owner = f.MembershipFactory(project=project, user=project.owner, is_admin=True) + membership = f.MembershipFactory(project=project, is_admin=True) + + url = reverse("memberships-detail", args=[membership_owner.id]) + data = {"is_admin": False} + + client.login(membership.user) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 400 + assert 'is_admin' in response.data + + +def test_api_delete_membership(client): + membership = f.MembershipFactory(is_admin=True) + client.login(membership.user) + url = reverse("memberships-detail", args=[membership.id]) + response = client.json.delete(url) + + assert response.status_code == 400 + + f.MembershipFactory(is_admin=True, project=membership.project) + + url = reverse("memberships-detail", args=[membership.id]) + response = client.json.delete(url) + + assert response.status_code == 204 + + +def test_api_delete_membership_without_user(client): + membership_owner = f.MembershipFactory(is_admin=True) + membership_without_user_one = f.MembershipFactory(project=membership_owner.project, user=None) + f.MembershipFactory(project=membership_owner.project, user=None) + client.login(membership_owner.user) + url = reverse("memberships-detail", args=[membership_without_user_one.id]) + response = client.json.delete(url) + + assert response.status_code == 204 + + +def test_api_create_member_max_pending_memberships(client, settings): + settings.MAX_PENDING_MEMBERSHIPS = 3 + project = f.ProjectFactory() + john = f.UserFactory.create() + tester = f.RoleFactory(project=project, name="Tester") + f.MembershipFactory(project=project, user=john, is_admin=True) + f.MembershipFactory(project=project, user=None, email="test1@mail.com") + f.MembershipFactory(project=project, user=None, email="test2@mail.com") + f.MembershipFactory(project=project, user=None, email="test3@mail.com") + + url = reverse("memberships-list") + data = {"project": project.id, "role": tester.id, "username": "joseph@email.com"} + client.login(john) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "limit of pending memberships" in response.data["_error_message"] + + +def test_api_create_bulk_members_max_pending_memberships(client, settings): + settings.MAX_PENDING_MEMBERSHIPS = 2 + project = f.ProjectFactory() + john = f.UserFactory.create() + joseph = f.UserFactory.create() + tester = f.RoleFactory(project=project, name="Tester") + f.MembershipFactory(project=project, user=john, is_admin=True) + f.MembershipFactory(project=project, user=None, email="test1@mail.com") + f.MembershipFactory(project=project, user=None, email="test2@mail.com") + + url = reverse("memberships-bulk-create") + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": tester.id, "username": "joseph@email.com"}, + ] + } + client.login(john) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "limit of pending memberships" in response.data["_error_message"] + + +def test_create_memberhips_throttling(client, settings): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["create-memberships"] = "1/minute" + + membership = f.MembershipFactory(is_admin=True) + role = f.RoleFactory.create(project=membership.project) + user = f.UserFactory.create() + user2 = f.UserFactory.create() + + client.login(membership.user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": role.project.pk, "username": user.email} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert response.data["user_email"] == user.email + + data = {"role": role.pk, "project": role.project.pk, "username": user2.email} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 429 + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["create-memberships"] = None + + +def test_api_resend_invitation_throttling(client, outbox, settings): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["create-memberships"] = "1/minute" + + invitation = f.create_invitation(user=None) + f.MembershipFactory(project=invitation.project, user=invitation.project.owner, is_admin=True) + url = reverse("memberships-resend-invitation", kwargs={"pk": invitation.pk}) + + client.login(invitation.project.owner) + response = client.post(url) + + assert response.status_code == 204 + assert len(outbox) == 1 + assert outbox[0].to == [invitation.email] + + response = client.post(url) + + assert response.status_code == 429 + assert len(outbox) == 1 + assert outbox[0].to == [invitation.email] + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["create-memberships"] = None + + +def test_api_create_bulk_members_throttling(client, settings): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["create-memberships"] = "2/minute" + + 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) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + response_user_ids = set([u["user"] for u in response.data]) + user_ids = {other.id, joseph.id} + assert(user_ids.issubset(response_user_ids)) + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 429 + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["create-memberships"] = None diff --git a/tests/integration/test_milestones.py b/tests/integration/test_milestones.py new file mode 100644 index 000000000..fe2178d87 --- /dev/null +++ b/tests/integration/test_milestones.py @@ -0,0 +1,377 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from datetime import timedelta +from urllib.parse import quote + +from django.urls import reverse +from django.utils import timezone + +from taiga.base.utils import json + +from .. import factories as f + + +pytestmark = pytest.mark.django_db + + +def test_update_milestone_with_userstories_list(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project) + f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + sprint = f.MilestoneFactory.create(project=project, owner=user) + f.PointsFactory.create(project=project, value=None) + us = f.UserStoryFactory.create(project=project, owner=user) + + url = reverse("milestones-detail", args=[sprint.pk]) + + form_data = { + "name": "test", + "user_stories": [{"id": us.id}] + } + + client.login(user) + response = client.json.patch(url, json.dumps(form_data)) + assert response.status_code == 200 + + +def test_list_milestones_taiga_info_headers(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project) + f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + + f.MilestoneFactory.create(project=project, owner=user, closed=True) + f.MilestoneFactory.create(project=project, owner=user, closed=True) + f.MilestoneFactory.create(project=project, owner=user, closed=True) + f.MilestoneFactory.create(project=project, owner=user, closed=False) + f.MilestoneFactory.create(owner=user, closed=False) + + url = reverse("milestones-list") + + client.login(project.owner) + response1 = client.json.get(url) + response2 = client.json.get(url, {"project": project.id}) + + assert response1.status_code == 200 + assert "taiga-info-total-closed-milestones" in response1["access-control-expose-headers"] + assert "taiga-info-total-opened-milestones" in response1["access-control-expose-headers"] + assert response1.has_header("Taiga-Info-Total-Closed-Milestones") == False + assert response1.has_header("Taiga-Info-Total-Opened-Milestones") == False + + assert response2.status_code == 200 + assert "taiga-info-total-closed-milestones" in response2["access-control-expose-headers"] + assert "taiga-info-total-opened-milestones" in response2["access-control-expose-headers"] + assert response2.has_header("Taiga-Info-Total-Closed-Milestones") == True + assert response2.has_header("Taiga-Info-Total-Opened-Milestones") == True + assert response2["taiga-info-total-closed-milestones"] == "3" + assert response2["taiga-info-total-opened-milestones"] == "1" + + +def test_api_filter_by_created_date__lte(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project) + f.MembershipFactory.create( + project=project, user=user, role=role, is_admin=True + ) + one_day_ago = timezone.now() - timedelta(days=1) + + old_milestone = f.MilestoneFactory.create( + project=project, owner=user, created_date=one_day_ago + ) + milestone = f.MilestoneFactory.create(project=project, owner=user) + + url = reverse("milestones-list") + "?created_date__lte=%s" % ( + quote(milestone.created_date.isoformat()) + ) + + client.login(milestone.owner) + response = client.get(url) + number_of_milestones = len(response.data) + + assert response.status_code == 200 + assert number_of_milestones == 2 + + +def test_api_filter_by_modified_date__gte(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project) + f.MembershipFactory.create( + project=project, user=user, role=role, is_admin=True + ) + one_day_ago = timezone.now() - timedelta(days=1) + + older_milestone = f.MilestoneFactory.create( + project=project, owner=user, created_date=one_day_ago + ) + milestone = f.MilestoneFactory.create(project=project, owner=user) + # we have to refresh as it slightly differs + milestone.refresh_from_db() + + assert older_milestone.modified_date < milestone.modified_date + + url = reverse("milestones-list") + "?modified_date__gte=%s" % ( + quote(milestone.modified_date.isoformat()) + ) + + client.login(milestone.owner) + response = client.get(url) + number_of_milestones = len(response.data) + + assert response.status_code == 200 + assert number_of_milestones == 1 + assert response.data[0]["slug"] == milestone.slug + + +@pytest.mark.parametrize("field_name", [ + "estimated_start", "estimated_finish" +]) +def test_api_filter_by_milestone__estimated_start_and_end(client, field_name): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project) + f.MembershipFactory.create( + project=project, user=user, role=role, is_admin=True + ) + milestone = f.MilestoneFactory.create(project=project, owner=user) + + assert hasattr(milestone, field_name) + date = getattr(milestone, field_name) + before = (date - timedelta(days=1)).isoformat() + after = (date + timedelta(days=1)).isoformat() + + client.login(milestone.owner) + + expections = { + field_name + "__gte=" + quote(before): 1, + field_name + "__gte=" + quote(after): 0, + field_name + "__lte=" + quote(before): 0, + field_name + "__lte=" + quote(after): 1 + } + + for param, expection in expections.items(): + url = reverse("milestones-list") + "?" + param + response = client.get(url) + number_of_milestones = len(response.data) + + assert response.status_code == 200 + 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 diff --git a/tests/integration/test_models.py b/tests/integration/test_models.py new file mode 100644 index 000000000..cd8e937d0 --- /dev/null +++ b/tests/integration/test_models.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from .. import factories as f +from ..utils import disconnect_signals, reconnect_signals + + +def setup_module(): + disconnect_signals() + + +def teardown_module(): + reconnect_signals() + + +pytestmark = pytest.mark.django_db + + +def test_project_update_role_points(): + """Test that relation to project roles are created for stories not related to those roles. + + The "relation" is just a mere `RolePoints` relation between the story and the role with + points set to the project's null-point. + """ + project = f.ProjectFactory.create() + related_role = f.RoleFactory.create(project=project, computable=True) + null_points = f.PointsFactory.create(project=project, value=None) + user_story = f.UserStoryFactory(project=project) + + new_related_role = f.RoleFactory.create(project=project, computable=True) + + assert user_story.role_points.count() == 1 + assert user_story.role_points.filter(role=new_related_role, points=null_points).count() == 0 + + project.update_role_points() + + assert user_story.role_points.count() == 2 + assert user_story.role_points.filter(role=new_related_role, points=null_points).count() == 1 diff --git a/tests/integration/test_neighbors.py b/tests/integration/test_neighbors.py new file mode 100644 index 000000000..6053bcb96 --- /dev/null +++ b/tests/integration/test_neighbors.py @@ -0,0 +1,343 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from taiga.projects.userstories.models import UserStory +from taiga.projects.issues.models import Issue +from taiga.base import neighbors as n + +from .. import factories as f +from ..utils import disconnect_signals, reconnect_signals + + +def setup_module(): + disconnect_signals() + + +def teardown_module(): + reconnect_signals() + + +@pytest.mark.django_db +class TestUserStories: + def test_no_filters(self): + project = f.ProjectFactory.create() + + us1 = f.UserStoryFactory.create(project=project) + us2 = f.UserStoryFactory.create(project=project) + us3 = f.UserStoryFactory.create(project=project) + + neighbors = n.get_neighbors(us2) + + assert neighbors.left == us1 + assert neighbors.right == us3 + + def test_results_set_repeat_id(self): + project = f.ProjectFactory.create() + + us1 = f.UserStoryFactory.create(project=project) + f.RolePointsFactory.create(user_story=us1) + f.RolePointsFactory.create(user_story=us1) + + neighbors = n.get_neighbors(us1, results_set=UserStory.objects.get_queryset().filter(role_points__isnull=False)) + + assert neighbors.right == us1 + + def test_results_set_left_repeat_id(self): + project = f.ProjectFactory.create() + + us1 = f.UserStoryFactory.create(project=project) + f.RolePointsFactory.create(user_story=us1) + f.RolePointsFactory.create(user_story=us1) + + us2 = f.UserStoryFactory.create(project=project) + f.RolePointsFactory.create(user_story=us2) + + neighbors = n.get_neighbors(us2, results_set=UserStory.objects.get_queryset().filter(role_points__isnull=False)) + + assert neighbors.left == us1 + + def test_filtered_by_tags(self): + tag_names = ["test"] + project = f.ProjectFactory.create() + + f.UserStoryFactory.create(project=project) + us1 = f.UserStoryFactory.create(project=project, tags=tag_names) + us2 = f.UserStoryFactory.create(project=project, tags=tag_names) + + test_user_stories = UserStory.objects.get_queryset().filter(tags__contains=tag_names) + + neighbors = n.get_neighbors(us1, results_set=test_user_stories) + + assert neighbors.left is None + assert neighbors.right == us2 + + def test_filtered_by_milestone(self): + project = f.ProjectFactory.create() + milestone = f.MilestoneFactory.create(project=project) + + f.UserStoryFactory.create(project=project) + us1 = f.UserStoryFactory.create(project=project, milestone=milestone) + us2 = f.UserStoryFactory.create(project=project, milestone=milestone) + + milestone_user_stories = UserStory.objects.filter(milestone=milestone) + + neighbors = n.get_neighbors(us1, results_set=milestone_user_stories) + + assert neighbors.left is None + assert neighbors.right == us2 + + +@pytest.mark.django_db +class TestIssues: + def test_no_filters(self): + project = f.ProjectFactory.create() + + issue1 = f.IssueFactory.create(project=project) + issue2 = f.IssueFactory.create(project=project) + issue3 = f.IssueFactory.create(project=project) + + neighbors = n.get_neighbors(issue2) + + assert neighbors.left == issue3 + assert neighbors.right == issue1 + + def test_empty_related_queryset(self): + project = f.ProjectFactory.create() + + issue1 = f.IssueFactory.create(project=project) + issue2 = f.IssueFactory.create(project=project) + issue3 = f.IssueFactory.create(project=project) + + neighbors = n.get_neighbors(issue2, Issue.objects.none()) + + assert neighbors.left == issue3 + assert neighbors.right == issue1 + + def test_ordering_by_severity(self): + project = f.ProjectFactory.create() + severity1 = f.SeverityFactory.create(project=project, order=1) + severity2 = f.SeverityFactory.create(project=project, order=2) + + issue1 = f.IssueFactory.create(project=project, severity=severity2) + issue2 = f.IssueFactory.create(project=project, severity=severity1) + issue3 = f.IssueFactory.create(project=project, severity=severity1) + + issues = Issue.objects.filter(project=project).order_by("severity", "-id") + + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + issue3_neighbors = n.get_neighbors(issue3, results_set=issues) + + assert issue3_neighbors.left is None + assert issue3_neighbors.right == issue2 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right == issue1 + + def test_ordering_by_severity_desc(self): + project = f.ProjectFactory.create() + severity1 = f.SeverityFactory.create(project=project, order=1) + severity2 = f.SeverityFactory.create(project=project, order=2) + + issue1 = f.IssueFactory.create(project=project, severity=severity2) + issue2 = f.IssueFactory.create(project=project, severity=severity1) + issue3 = f.IssueFactory.create(project=project, severity=severity1) + + issues = Issue.objects.filter(project=project).order_by("-severity", "-id") + + issue1_neighbors = n.get_neighbors(issue1, results_set=issues) + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + + assert issue1_neighbors.left is None + assert issue1_neighbors.right == issue3 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right is None + + def test_ordering_by_status(self): + project = f.ProjectFactory.create() + status1 = f.IssueStatusFactory.create(project=project, order=1) + status2 = f.IssueStatusFactory.create(project=project, order=2) + + issue1 = f.IssueFactory.create(project=project, status=status2) + issue2 = f.IssueFactory.create(project=project, status=status1) + issue3 = f.IssueFactory.create(project=project, status=status1) + + issues = Issue.objects.filter(project=project).order_by("status", "-id") + + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + issue3_neighbors = n.get_neighbors(issue3, results_set=issues) + + assert issue3_neighbors.left is None + assert issue3_neighbors.right == issue2 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right == issue1 + + def test_ordering_by_status_desc(self): + project = f.ProjectFactory.create() + status1 = f.IssueStatusFactory.create(project=project, order=1) + status2 = f.IssueStatusFactory.create(project=project, order=2) + + issue1 = f.IssueFactory.create(project=project, status=status2) + issue2 = f.IssueFactory.create(project=project, status=status1) + issue3 = f.IssueFactory.create(project=project, status=status1) + + issues = Issue.objects.filter(project=project).order_by("-status", "-id") + + issue1_neighbors = n.get_neighbors(issue1, results_set=issues) + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + + assert issue1_neighbors.left is None + assert issue1_neighbors.right == issue3 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right is None + + def test_ordering_by_priority(self): + project = f.ProjectFactory.create() + priority1 = f.PriorityFactory.create(project=project, order=1) + priority2 = f.PriorityFactory.create(project=project, order=2) + + issue1 = f.IssueFactory.create(project=project, priority=priority2) + issue2 = f.IssueFactory.create(project=project, priority=priority1) + issue3 = f.IssueFactory.create(project=project, priority=priority1) + + issues = Issue.objects.filter(project=project).order_by("priority", "-id") + + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + issue3_neighbors = n.get_neighbors(issue3, results_set=issues) + + assert issue3_neighbors.left is None + assert issue3_neighbors.right == issue2 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right == issue1 + + def test_ordering_by_priority_desc(self): + project = f.ProjectFactory.create() + priority1 = f.PriorityFactory.create(project=project, order=1) + priority2 = f.PriorityFactory.create(project=project, order=2) + + issue1 = f.IssueFactory.create(project=project, priority=priority2) + issue2 = f.IssueFactory.create(project=project, priority=priority1) + issue3 = f.IssueFactory.create(project=project, priority=priority1) + + issues = Issue.objects.filter(project=project).order_by("-priority", "-id") + + issue1_neighbors = n.get_neighbors(issue1, results_set=issues) + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + + assert issue1_neighbors.left is None + assert issue1_neighbors.right == issue3 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right is None + + def test_ordering_by_owner(self): + project = f.ProjectFactory.create() + owner1 = f.UserFactory.create(full_name="Chuck Norris") + owner2 = f.UserFactory.create(full_name="George Of The Jungle") + + issue1 = f.IssueFactory.create(project=project, owner=owner2) + issue2 = f.IssueFactory.create(project=project, owner=owner1) + issue3 = f.IssueFactory.create(project=project, owner=owner1) + + issues = Issue.objects.filter(project=project).order_by("owner__full_name", "-id") + + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + issue3_neighbors = n.get_neighbors(issue3, results_set=issues) + + assert issue3_neighbors.left is None + assert issue3_neighbors.right == issue2 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right == issue1 + + def test_ordering_by_owner_desc(self): + project = f.ProjectFactory.create() + owner1 = f.UserFactory.create(full_name="Chuck Norris") + owner2 = f.UserFactory.create(full_name="George Of The Jungle") + + issue1 = f.IssueFactory.create(project=project, owner=owner2) + issue2 = f.IssueFactory.create(project=project, owner=owner1) + issue3 = f.IssueFactory.create(project=project, owner=owner1) + + issues = Issue.objects.filter(project=project).order_by("-owner__full_name", "-id") + + issue1_neighbors = n.get_neighbors(issue1, results_set=issues) + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + + assert issue1_neighbors.left is None + assert issue1_neighbors.right == issue3 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right is None + + def test_ordering_by_assigned_to(self): + project = f.ProjectFactory.create() + assigned_to1 = f.UserFactory.create(full_name="Chuck Norris") + assigned_to2 = f.UserFactory.create(full_name="George Of The Jungle") + + issue1 = f.IssueFactory.create(project=project, assigned_to=assigned_to2) + issue2 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) + issue3 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) + + issues = Issue.objects.filter(project=project).order_by("assigned_to__full_name", "-id") + + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + issue3_neighbors = n.get_neighbors(issue3, results_set=issues) + + assert issue3_neighbors.left is None + assert issue3_neighbors.right == issue2 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right == issue1 + + def test_ordering_by_assigned_to_desc(self): + project = f.ProjectFactory.create() + assigned_to1 = f.UserFactory.create(full_name="Chuck Norris") + assigned_to2 = f.UserFactory.create(full_name="George Of The Jungle") + + issue1 = f.IssueFactory.create(project=project, assigned_to=assigned_to2) + issue2 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) + issue3 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) + + issues = Issue.objects.filter(project=project).order_by("-assigned_to__full_name", "-id") + + issue1_neighbors = n.get_neighbors(issue1, results_set=issues) + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + + assert issue1_neighbors.left is None + assert issue1_neighbors.right == issue3 + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right is None + + def test_ordering_by_assigned_to_desc_with_none_values(self): + project = f.ProjectFactory.create() + + issue1 = f.IssueFactory.create(project=project, assigned_to=None) + issue2 = f.IssueFactory.create(project=project, assigned_to=None) + issue3 = f.IssueFactory.create(project=project, assigned_to=None) + + issues = Issue.objects.filter(project=project).order_by("-assigned_to__full_name", "-id") + issue1_neighbors = n.get_neighbors(issue1, results_set=issues) + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + + assert issue1_neighbors.left == issue2 + assert issue1_neighbors.right is None + assert issue2_neighbors.left == issue3 + assert issue2_neighbors.right == issue1 + + def test_ordering_by_assigned_to_desc_with_none_and_normal_values(self): + project = f.ProjectFactory.create() + assigned_to1 = f.UserFactory.create(full_name="Chuck Norris") + issue1 = f.IssueFactory.create(project=project, assigned_to=None) + issue2 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) + issue3 = f.IssueFactory.create(project=project, assigned_to=None) + + issues = Issue.objects.filter(project=project).order_by("-assigned_to__full_name", "-id") + + issue1_neighbors = n.get_neighbors(issue1, results_set=issues) + issue2_neighbors = n.get_neighbors(issue2, results_set=issues) + + assert issue1_neighbors.left == issue3 + assert issue1_neighbors.right == issue2 + assert issue2_neighbors.left == issue1 + assert issue2_neighbors.right is None diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py new file mode 100644 index 000000000..e5265d650 --- /dev/null +++ b/tests/integration/test_notifications.py @@ -0,0 +1,1514 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import time +import base64 +import datetime +import hashlib +import binascii +import struct +import pytz +import smtplib + +from unittest.mock import Mock, MagicMock, patch + +from django.urls import reverse +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 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.permissions.choices import MEMBERS_PERMISSIONS +from taiga.users.gravatar import get_user_gravatar_id + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def mail(): + from django.core import mail + mail.outbox = [] + 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() + + policy_model_cls = apps.get_model("notifications", "NotifyPolicy") + current_number = policy_model_cls.objects.all().count() + assert current_number == 0 + + policy = project.cached_notify_policy_for_user(project.owner) + + current_number = policy_model_cls.objects.all().count() + assert current_number == 1 + assert policy.notify_level == NotifyLevel.involved + + +def test_notify_policy_existence(): + project = f.ProjectFactory.create() + assert not services.notify_policy_exists(project, project.owner) + + services.create_notify_policy(project, project.owner, NotifyLevel.all) + assert services.notify_policy_exists(project, project.owner) + + +def test_analize_object_for_watchers(): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + project = f.ProjectFactory.create() + + f.MembershipFactory(user=user1, project=project) + + issue = f.IssueFactory( + project=project, + description="Foo @{0} @{1} ".format(user1.username, user2.username), + ) + issue.add_watcher = MagicMock() + + history = MagicMock() + history.comment = "" + + services.analize_object_for_watchers(issue, history.comment, history.owner) + assert issue.add_watcher.call_count == 1 + + +def test_analize_object_for_watchers_adding_owner_non_empty_comment(): + user1 = f.UserFactory.create() + + issue = MagicMock() + issue.description = "Foo" + issue.content = "" + + history = MagicMock() + history.comment = "Comment" + history.owner = user1 + + services.analize_object_for_watchers(issue, history.comment, history.owner) + assert issue.add_watcher.call_count == 1 + + +def test_analize_object_for_watchers_no_adding_owner_empty_comment(): + user1 = f.UserFactory.create() + + issue = MagicMock() + issue.description = "Foo" + issue.content = "" + + history = MagicMock() + history.comment = "" + history.owner = user1 + + services.analize_object_for_watchers(issue, history.comment, history.owner) + assert issue.add_watcher.call_count == 0 + + +def test_users_to_notify(): + project = f.ProjectFactory.create() + role1 = f.RoleFactory.create(project=project, permissions=['view_issues']) + role2 = f.RoleFactory.create(project=project, permissions=[]) + + member1 = f.MembershipFactory.create(project=project, role=role1) + policy_member1 = member1.user.notify_policies.get(project=project) + policy_member1.notify_level = NotifyLevel.none + policy_member1.save() + member2 = f.MembershipFactory.create(project=project, role=role1) + policy_member2 = member2.user.notify_policies.get(project=project) + policy_member2.notify_level = NotifyLevel.none + policy_member2.save() + member3 = f.MembershipFactory.create(project=project, role=role1) + policy_member3 = member3.user.notify_policies.get(project=project) + policy_member3.notify_level = NotifyLevel.none + policy_member3.save() + member4 = f.MembershipFactory.create(project=project, role=role1) + policy_member4 = member4.user.notify_policies.get(project=project) + policy_member4.notify_level = NotifyLevel.none + policy_member4.save() + member5 = f.MembershipFactory.create(project=project, role=role2) + policy_member5 = member5.user.notify_policies.get(project=project) + policy_member5.notify_level = NotifyLevel.none + policy_member5.save() + inactive_member1 = f.MembershipFactory.create(project=project, role=role1) + inactive_member1.user.is_active = False + inactive_member1.user.save() + system_member1 = f.MembershipFactory.create(project=project, role=role1) + system_member1.user.is_system = True + system_member1.user.save() + + issue = f.IssueFactory.create(project=project, owner=member4.user) + + policy_model_cls = apps.get_model("notifications", "NotifyPolicy") + + policy_inactive_member1 = policy_model_cls.objects.get(user=inactive_member1.user) + policy_inactive_member1.notify_level = NotifyLevel.all + policy_inactive_member1.save() + + policy_system_member1 = policy_model_cls.objects.get(user=system_member1.user) + policy_system_member1.notify_level = NotifyLevel.all + policy_system_member1.save() + + history = MagicMock() + history.owner = member2.user + history.comment = "" + + # Test basic description modifications + issue.description = "test1" + issue.save() + policy_member4.notify_level = NotifyLevel.all + policy_member4.save() + users = services.get_users_to_notify(issue) + assert len(users) == 1 + assert tuple(users)[0] == issue.get_owner() + + # Test watch notify level in one member + policy_member1.notify_level = NotifyLevel.all + policy_member1.save() + + del project.cached_notify_policies + users = services.get_users_to_notify(issue) + assert len(users) == 2 + assert users == {member1.user, issue.get_owner()} + + # Test with watchers + issue.add_watcher(member3.user) + policy_member3.notify_level = NotifyLevel.all + policy_member3.save() + + del project.cached_notify_policies + users = services.get_users_to_notify(issue) + assert len(users) == 3 + assert users == {member1.user, member3.user, issue.get_owner()} + + # Test with watchers with ignore policy + policy_member3.notify_level = NotifyLevel.none + policy_member3.save() + + issue.add_watcher(member3.user) + del project.cached_notify_policies + users = services.get_users_to_notify(issue) + assert len(users) == 2 + assert users == {member1.user, issue.get_owner()} + + # Test with watchers without permissions + issue.add_watcher(member5.user) + del project.cached_notify_policies + users = services.get_users_to_notify(issue) + assert len(users) == 2 + assert users == {member1.user, issue.get_owner()} + + # Test with inactive user + issue.add_watcher(inactive_member1.user) + assert len(users) == 2 + assert users == {member1.user, issue.get_owner()} + + # Test with system user + issue.add_watcher(system_member1.user) + assert len(users) == 2 + assert users == {member1.user, issue.get_owner()} + + +def test_watching_users_to_notify_on_issue_modification_1(): + # If: + # - the user is watching the issue + # - the user is not watching the project + # - the notify policy is watch + # Then: + # - email is sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + issue.add_watcher(watching_user) + watching_user_policy = project.cached_notify_policy_for_user(watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.all + users = services.get_users_to_notify(issue) + assert users == {watching_user, issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_2(): + # If: + # - the user is watching the issue + # - the user is not watching the project + # - the notify policy is involved + # Then: + # - email is sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + issue.add_watcher(watching_user) + watching_user_policy = project.cached_notify_policy_for_user(watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.involved + users = services.get_users_to_notify(issue) + assert users == {watching_user, issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_3(): + # If: + # - the user is watching the issue + # - the user is not watching the project + # - the notify policy is ignore + # Then: + # - email is not sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + issue.add_watcher(watching_user) + watching_user_policy = project.cached_notify_policy_for_user(watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.none + watching_user_policy.save() + users = services.get_users_to_notify(issue) + assert users == {issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_4(): + # If: + # - the user is not watching the issue + # - the user is watching the project + # - the notify policy is ignore + # Then: + # - email is not sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + project.add_watcher(watching_user) + watching_user_policy = project.cached_notify_policy_for_user(watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.none + watching_user_policy.save() + users = services.get_users_to_notify(issue) + assert users == {issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_5(): + # If: + # - the user is not watching the issue + # - the user is watching the project + # - the notify policy is watch + # Then: + # - email is sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + project.add_watcher(watching_user) + watching_user_policy = project.cached_notify_policy_for_user(watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.all + watching_user_policy.save() + users = services.get_users_to_notify(issue) + assert users == {watching_user, issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_6(): + # If: + # - the user is not watching the issue + # - the user is watching the project + # - the notify policy is involved + # Then: + # - email is sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + project.add_watcher(watching_user) + watching_user_policy = project.cached_notify_policy_for_user(watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.involved + watching_user_policy.save() + users = services.get_users_to_notify(issue) + assert users == {issue.owner} + + +def test_send_notifications_using_services_method_for_user_stories(settings, mail): + settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 + + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['view_issues', 'view_us', 'view_tasks', 'view_wiki_pages']) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + + us = f.UserStoryFactory.create(project=project, owner=member2.user) + history_change = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:change", + type=HistoryType.change, + key="userstories.userstory:{}".format(us.id), + is_hidden=False, + diff=[] + ) + + history_create = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="", + type=HistoryType.create, + key="userstories.userstory:{}".format(us.id), + is_hidden=False, + diff=[] + ) + + history_delete = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:delete", + type=HistoryType.delete, + key="userstories.userstory:{}".format(us.id), + is_hidden=False, + diff=[] + ) + + take_snapshot(us, user=us.owner) + services.send_notifications(us, + history=history_create) + + services.send_notifications(us, + history=history_change) + + services.send_notifications(us, + history=history_delete) + + assert models.HistoryChangeNotification.objects.count() == 3 + assert len(mail.outbox) == 0 + time.sleep(1) + services.process_sync_notifications() + assert len(mail.outbox) == 3 + + # test headers + domain = settings.SITES["api"]["domain"].split(":")[0] or settings.SITES["api"]["domain"] + for msg in mail.outbox: + m_id = "{project_slug}/{msg_id}".format( + project_slug=project.slug, + msg_id=us.ref + ) + + message_id = "<{m_id}/".format(m_id=m_id) + message_id_domain = "@{domain}>".format(domain=domain) + in_reply_to = "<{m_id}@{domain}>".format(m_id=m_id, domain=domain) + list_id = "Taiga/{p_name} " \ + .format(p_name=project.name, p_slug=project.slug, domain=domain) + + assert msg.extra_headers + headers = msg.extra_headers + + # can't test the time part because it's set when sending + # check what we can + assert 'Message-ID' in headers + assert message_id in headers.get('Message-ID') + assert message_id_domain in headers.get('Message-ID') + + assert 'In-Reply-To' in headers + assert in_reply_to == headers.get('In-Reply-To') + assert 'References' in headers + assert in_reply_to == headers.get('References') + + assert 'List-ID' in headers + assert list_id == headers.get('List-ID') + + assert 'Thread-Index' in headers + + # hashes should match for identical ids and times + # we check the actual method in test_ms_thread_id() + msg_time = headers.get('Message-ID').split('/')[2].split('@')[0] + msg_ts = datetime.datetime.fromtimestamp(int(msg_time)) + assert services.make_ms_thread_index(in_reply_to, msg_ts) == headers.get('Thread-Index') + + +def test_send_notifications_using_services_method_for_tasks(settings, mail): + settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 + + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['view_issues', 'view_us', 'view_tasks', 'view_wiki_pages']) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + + task = f.TaskFactory.create(project=project, owner=member2.user) + history_change = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:change", + type=HistoryType.change, + key="tasks.task:{}".format(task.id), + is_hidden=False, + diff=[] + ) + + history_create = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="", + type=HistoryType.create, + key="tasks.task:{}".format(task.id), + is_hidden=False, + diff=[] + ) + + history_delete = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:delete", + type=HistoryType.delete, + key="tasks.task:{}".format(task.id), + is_hidden=False, + diff=[] + ) + + take_snapshot(task, user=task.owner) + services.send_notifications(task, + history=history_create) + + services.send_notifications(task, + history=history_change) + + services.send_notifications(task, + history=history_delete) + + assert models.HistoryChangeNotification.objects.count() == 3 + assert len(mail.outbox) == 0 + time.sleep(1) + services.process_sync_notifications() + assert len(mail.outbox) == 3 + + # test headers + domain = settings.SITES["api"]["domain"].split(":")[0] or settings.SITES["api"]["domain"] + for msg in mail.outbox: + m_id = "{project_slug}/{msg_id}".format( + project_slug=project.slug, + msg_id=task.ref + ) + + message_id = "<{m_id}/".format(m_id=m_id) + message_id_domain = "@{domain}>".format(domain=domain) + in_reply_to = "<{m_id}@{domain}>".format(m_id=m_id, domain=domain) + list_id = "Taiga/{p_name} " \ + .format(p_name=project.name, p_slug=project.slug, domain=domain) + + assert msg.extra_headers + headers = msg.extra_headers + + # can't test the time part because it's set when sending + # check what we can + assert 'Message-ID' in headers + assert message_id in headers.get('Message-ID') + assert message_id_domain in headers.get('Message-ID') + + assert 'In-Reply-To' in headers + assert in_reply_to == headers.get('In-Reply-To') + assert 'References' in headers + assert in_reply_to == headers.get('References') + + assert 'List-ID' in headers + assert list_id == headers.get('List-ID') + + assert 'Thread-Index' in headers + + # hashes should match for identical ids and times + # we check the actual method in test_ms_thread_id() + msg_time = headers.get('Message-ID').split('/')[2].split('@')[0] + msg_ts = datetime.datetime.fromtimestamp(int(msg_time)) + assert services.make_ms_thread_index(in_reply_to, msg_ts) == headers.get('Thread-Index') + + +def test_send_notifications_using_services_method_for_issues(settings, mail): + settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 + + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['view_issues', 'view_us', 'view_tasks', 'view_wiki_pages']) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + + issue = f.IssueFactory.create(project=project, owner=member2.user) + history_change = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:change", + type=HistoryType.change, + key="issues.issue:{}".format(issue.id), + is_hidden=False, + diff=[] + ) + + history_create = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="", + type=HistoryType.create, + key="issues.issue:{}".format(issue.id), + is_hidden=False, + diff=[] + ) + + history_delete = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:delete", + type=HistoryType.delete, + key="issues.issue:{}".format(issue.id), + is_hidden=False, + diff=[] + ) + + take_snapshot(issue, user=issue.owner) + services.send_notifications(issue, + history=history_create) + + services.send_notifications(issue, + history=history_change) + + services.send_notifications(issue, + history=history_delete) + + assert models.HistoryChangeNotification.objects.count() == 3 + assert len(mail.outbox) == 0 + time.sleep(1) + services.process_sync_notifications() + assert len(mail.outbox) == 3 + + # test headers + domain = settings.SITES["api"]["domain"].split(":")[0] or settings.SITES["api"]["domain"] + for msg in mail.outbox: + m_id = "{project_slug}/{msg_id}".format( + project_slug=project.slug, + msg_id=issue.ref + ) + + message_id = "<{m_id}/".format(m_id=m_id) + message_id_domain = "@{domain}>".format(domain=domain) + in_reply_to = "<{m_id}@{domain}>".format(m_id=m_id, domain=domain) + list_id = "Taiga/{p_name} " \ + .format(p_name=project.name, p_slug=project.slug, domain=domain) + + assert msg.extra_headers + headers = msg.extra_headers + + # can't test the time part because it's set when sending + # check what we can + assert 'Message-ID' in headers + assert message_id in headers.get('Message-ID') + assert message_id_domain in headers.get('Message-ID') + + assert 'In-Reply-To' in headers + assert in_reply_to == headers.get('In-Reply-To') + assert 'References' in headers + assert in_reply_to == headers.get('References') + + assert 'List-ID' in headers + assert list_id == headers.get('List-ID') + + assert 'Thread-Index' in headers + + # hashes should match for identical ids and times + # we check the actual method in test_ms_thread_id() + msg_time = headers.get('Message-ID').split('/')[2].split('@')[0] + msg_ts = datetime.datetime.fromtimestamp(int(msg_time)) + assert services.make_ms_thread_index(in_reply_to, msg_ts) == headers.get('Thread-Index') + + +def test_send_notifications_using_services_method_for_wiki_pages(settings, mail): + settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 + + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['view_issues', 'view_us', 'view_tasks', 'view_wiki_pages']) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + + wiki = f.WikiPageFactory.create(project=project, owner=member2.user) + history_change = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:change", + type=HistoryType.change, + key="wiki.wikipage:{}".format(wiki.id), + is_hidden=False, + diff=[] + ) + + history_create = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="", + type=HistoryType.create, + key="wiki.wikipage:{}".format(wiki.id), + is_hidden=False, + diff=[] + ) + + history_delete = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:delete", + type=HistoryType.delete, + key="wiki.wikipage:{}".format(wiki.id), + is_hidden=False, + diff=[] + ) + take_snapshot(wiki, user=wiki.owner) + services.send_notifications(wiki, + history=history_create) + + services.send_notifications(wiki, + history=history_change) + + services.send_notifications(wiki, + history=history_delete) + + assert models.HistoryChangeNotification.objects.count() == 3 + assert len(mail.outbox) == 0 + time.sleep(1) + services.process_sync_notifications() + assert len(mail.outbox) == 3 + + # test headers + domain = settings.SITES["api"]["domain"].split(":")[0] or settings.SITES["api"]["domain"] + for msg in mail.outbox: + m_id = "{project_slug}/{msg_id}".format( + project_slug=project.slug, + msg_id=wiki.slug + ) + + message_id = "<{m_id}/".format(m_id=m_id) + message_id_domain = "@{domain}>".format(domain=domain) + in_reply_to = "<{m_id}@{domain}>".format(m_id=m_id, domain=domain) + list_id = "Taiga/{p_name} " \ + .format(p_name=project.name, p_slug=project.slug, domain=domain) + + assert msg.extra_headers + headers = msg.extra_headers + + # can't test the time part because it's set when sending + # check what we can + assert 'Message-ID' in headers + assert message_id in headers.get('Message-ID') + assert message_id_domain in headers.get('Message-ID') + + assert 'In-Reply-To' in headers + assert in_reply_to == headers.get('In-Reply-To') + assert 'References' in headers + assert in_reply_to == headers.get('References') + + assert 'List-ID' in headers + assert list_id == headers.get('List-ID') + + assert 'Thread-Index' in headers + + # hashes should match for identical ids and times + # we check the actual method in test_ms_thread_id() + msg_time = headers.get('Message-ID').split('/')[2].split('@')[0] + msg_ts = datetime.datetime.fromtimestamp(int(msg_time)) + assert services.make_ms_thread_index(in_reply_to, msg_ts) == headers.get('Thread-Index') + + +def test_send_notifications_on_unassigned(client, mail): + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['modify_issue', 'view_issues', 'view_us', 'view_tasks', 'view_wiki_pages']) + 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, + milestone=None, + status=project.default_issue_status, + severity=project.default_severity, + priority=project.default_priority, + type=project.default_issue_type) + + take_snapshot(issue, user=issue.owner) + + client.login(member1.user) + url = reverse("issues-detail", args=[issue.pk]) + data = { + "assigned_to": member2.user.id, + "version": issue.version + } + response = client.json.patch(url, json.dumps(data)) + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [member2.user.email] + + mail.outbox = [] + + data = { + "assigned_to": None, + "version": issue.version + 1 + } + response = client.json.patch(url, json.dumps(data)) + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [member2.user.email] + + +def test_send_notifications_on_unassigned_and_notifications_are_disabled(client, mail): + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['modify_issue', 'view_issues', 'view_us', 'view_tasks', 'view_wiki_pages']) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + + member2_notify_policy = member2.user.notify_policies.get(project=project) + member2_notify_policy.notify_level = NotifyLevel.none + member2_notify_policy.save() + + issue = f.IssueFactory.create(project=project, + owner=member1.user, + milestone=None, + status=project.default_issue_status, + severity=project.default_severity, + priority=project.default_priority, + type=project.default_issue_type) + + take_snapshot(issue, user=issue.owner) + + client.login(member1.user) + url = reverse("issues-detail", args=[issue.pk]) + data = { + "assigned_to": member2.user.id, + "version": issue.version + } + response = client.json.patch(url, json.dumps(data)) + assert len(mail.outbox) == 0 + + mail.outbox = [] + + data = { + "assigned_to": None, + "version": issue.version + 1 + } + response = client.json.patch(url, json.dumps(data)) + assert len(mail.outbox) == 0 + + + +def test_not_send_notifications_on_unassigned_if_executer_and_unassigned_match(client, mail): + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['modify_issue', 'view_issues', 'view_us', 'view_tasks', 'view_wiki_pages']) + 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, + milestone=None, + status=project.default_issue_status, + severity=project.default_severity, + priority=project.default_priority, + type=project.default_issue_type) + + take_snapshot(issue, user=issue.owner) + + client.login(member1.user) + url = reverse("issues-detail", args=[issue.pk]) + data = { + "assigned_to": member1.user.id, + "version": issue.version + } + response = client.json.patch(url, json.dumps(data)) + assert len(mail.outbox) == 0 + + mail.outbox = [] + + data = { + "assigned_to": None, + "version": issue.version + 1 + } + response = client.json.patch(url, json.dumps(data)) + assert len(mail.outbox) == 0 + + +def test_resource_notification_test(client, settings, mail): + settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 + + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user1) + role = f.RoleFactory.create(project=project, permissions=["view_issues"]) + f.MembershipFactory.create(project=project, user=user1, role=role, is_admin=True) + f.MembershipFactory.create(project=project, user=user2, role=role) + issue = f.IssueFactory.create(owner=user2, project=project) + + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + url = reverse("issues-detail", args=[issue.pk]) + + client.login(user1) + + with patch(mock_path): + data = {"subject": "Fooooo", "version": issue.version} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + assert len(mail.outbox) == 0 + assert models.HistoryChangeNotification.objects.count() == 1 + time.sleep(1) + services.process_sync_notifications() + assert len(mail.outbox) == 1 + assert models.HistoryChangeNotification.objects.count() == 0 + + with patch(mock_path): + response = client.delete(url) + assert response.status_code == 204 + assert len(mail.outbox) == 1 + assert models.HistoryChangeNotification.objects.count() == 1 + time.sleep(1) + services.process_sync_notifications() + assert len(mail.outbox) == 2 + assert models.HistoryChangeNotification.objects.count() == 0 + + +def test_watchers_assignation_for_issue(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + project1 = f.ProjectFactory.create(owner=user1) + project2 = f.ProjectFactory.create(owner=user2) + role1 = f.RoleFactory.create(project=project1) + role2 = f.RoleFactory.create(project=project2) + f.MembershipFactory.create(project=project1, user=user1, role=role1, is_admin=True) + f.MembershipFactory.create(project=project2, user=user2, role=role2) + + client.login(user1) + + issue = f.create_issue(project=project1, owner=user1) + data = {"version": issue.version, + "watchersa": [user1.pk]} + + url = reverse("issues-detail", args=[issue.pk]) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, str(response.content) + + issue = f.create_issue(project=project1, owner=user1) + data = {"version": issue.version, + "watchers": [user1.pk, user2.pk]} + + url = reverse("issues-detail", args=[issue.pk]) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + issue = f.create_issue(project=project1, owner=user1) + data = {} + data["id"] = None + data["version"] = None + data["watchers"] = [user1.pk, user2.pk] + + url = reverse("issues-list") + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + # Test the impossible case when project is not + # exists in create request, and validator works as expected + issue = f.create_issue(project=project1, owner=user1) + data = {} + data["id"] = None + data["watchers"] = [user1.pk, user2.pk] + data["project"] = None + + url = reverse("issues-list") + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_watchers_assignation_for_task(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + project1 = f.ProjectFactory.create(owner=user1) + project2 = f.ProjectFactory.create(owner=user2) + role1 = f.RoleFactory.create(project=project1, permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + role2 = f.RoleFactory.create(project=project2) + f.MembershipFactory.create(project=project1, user=user1, role=role1, is_admin=True) + f.MembershipFactory.create(project=project2, user=user2, role=role2) + + client.login(user1) + + task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1, user_story=None) + data = {"version": task.version, + "watchers": [user1.pk]} + + url = reverse("tasks-detail", args=[task.pk]) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, str(response.content) + + task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1) + data = {"version": task.version, + "watchers": [user1.pk, user2.pk]} + + url = reverse("tasks-detail", args=[task.pk]) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1) + data = { + "id": None, + "version": None, + "watchers": [user1.pk, user2.pk] + } + + url = reverse("tasks-list") + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + # Test the impossible case when project is not + # exists in create request, and validator works as expected + task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1) + data = { + "id": None, + "watchers": [user1.pk, user2.pk], + "project": None + } + + url = reverse("tasks-list") + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_watchers_assignation_for_us(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + project1 = f.ProjectFactory.create(owner=user1) + project2 = f.ProjectFactory.create(owner=user2) + role1 = f.RoleFactory.create(project=project1) + role2 = f.RoleFactory.create(project=project2) + f.MembershipFactory.create(project=project1, user=user1, role=role1, is_admin=True) + f.MembershipFactory.create(project=project2, user=user2, role=role2) + + client.login(user1) + + us = f.create_userstory(project=project1, owner=user1, status__project=project1) + data = {"version": us.version, + "watchers": [user1.pk]} + + url = reverse("userstories-detail", args=[us.pk]) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, str(response.content) + + us = f.create_userstory(project=project1, owner=user1, status__project=project1) + data = {"version": us.version, + "watchers": [user1.pk, user2.pk]} + + url = reverse("userstories-detail", args=[us.pk]) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + us = f.create_userstory(project=project1, owner=user1, status__project=project1) + data = { + "id": None, + "version": None, + "watchers": [user1.pk, user2.pk] + } + + url = reverse("userstories-list") + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + # Test the impossible case when project is not + # exists in create request, and validator works as expected + us = f.create_userstory(project=project1, owner=user1, status__project=project1) + data = { + "id": None, + "watchers": [user1.pk, user2.pk], + "project": None + } + + url = reverse("userstories-list") + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_retrieve_notify_policies_by_anonymous_user(client): + project = f.ProjectFactory.create() + + policy = project.cached_notify_policy_for_user(project.owner) + + url = reverse("notifications-detail", args=[policy.pk]) + response = client.get(url, content_type="application/json") + assert response.status_code == 404, response.status_code + assert response.data["_error_message"] == "No NotifyPolicy matches the given query.", str(response.content) + + +def test_ms_thread_id(): + id = '' + now = timezone.now() + + index = services.make_ms_thread_index(id, now) + parsed = parse_ms_thread_index(index) + + assert parsed[0] == hashlib.md5(id.encode('utf-8')).hexdigest() + # always only one time + assert (now - parsed[1][0]).seconds <= 2 + + +# see http://stackoverflow.com/questions/27374077/parsing-thread-index-mail-header-with-python +def parse_ms_thread_index(index): + s = base64.b64decode(index) + + # ours are always md5 digests + guid = binascii.hexlify(s[6:22]).decode('utf-8') + + # if we had real guids, we'd do something like + # guid = struct.unpack('>IHHQ', s[6:22]) + # guid = '%08X-%04X-%04X-%04X-%12X' % (guid[0], guid[1], guid[2], (guid[3] >> 48) & 0xFFFF, guid[3] & 0xFFFFFFFFFFFF) + + f = struct.unpack('>Q', s[:6] + b'\0\0')[0] + ts = [datetime.datetime(1601, 1, 1, tzinfo=pytz.utc) + datetime.timedelta(microseconds=f//10)] + + # for the 5 byte appendixes that we won't use + for n in range(22, len(s), 5): + f = struct.unpack('>I', s[n:n+4])[0] + 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.create_userstory( + project=project, + owner=member1.user, + assigned_to=member2.user, + assigned_users =[], + 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, + "watchers": [member3.user.pk], + "version": us.version + }), + content_type="application/json" + ) + + assert 2 == 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 + + +def test_us_updated_generates_web_notifications_asigned_users(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.create_userstory(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({ + "assigned_users": [member2.user.pk], + "version": us.version + }), + content_type="application/json" + ) + + assert 1 == models.WebNotification.objects.count() + + notifications = models.WebNotification.objects.all() + notification_data = _notification_data(project, member1.user, us, 'userstory') + + 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 + +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.create_userstory(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 + + +def test_smtp_error_sending_notifications(settings, mail): + settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 + + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['view_issues', 'view_us', 'view_tasks', 'view_wiki_pages']) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) + + task = f.TaskFactory.create(project=project, owner=member2.user) + history_change = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:change", + type=HistoryType.change, + key="tasks.task:{}".format(task.id), + is_hidden=False, + diff=[] + ) + + history_create = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="", + type=HistoryType.create, + key="tasks.task:{}".format(task.id), + is_hidden=False, + diff=[] + ) + + history_delete = f.HistoryEntryFactory.create( + project=project, + user={"pk": member1.user.id}, + comment="test:delete", + type=HistoryType.delete, + key="tasks.task:{}".format(task.id), + is_hidden=False, + diff=[] + ) + + take_snapshot(task, user=task.owner) + services.send_notifications(task, + history=history_create) + + services.send_notifications(task, + history=history_change) + + services.send_notifications(task, + history=history_delete) + + + with patch("taiga.projects.notifications.services._make_template_mail") as make_template_email_mock, \ + patch("taiga.projects.notifications.services.logger") as logger_mock: + email_mock = Mock() + email_mock.send.side_effect = smtplib.SMTPDataError(msg="error smtp", code=123) + make_template_email_mock.return_value = email_mock + + assert models.HistoryChangeNotification.objects.count() == 3 + assert len(mail.outbox) == 0 + time.sleep(1) + services.process_sync_notifications() + assert len(mail.outbox) == 0 + + assert logger_mock.exception.call_count == 3 diff --git a/tests/integration/test_notifications_custom.py b/tests/integration/test_notifications_custom.py new file mode 100644 index 000000000..6f32dab17 --- /dev/null +++ b/tests/integration/test_notifications_custom.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.conf import settings + +from .. import factories as f + +from taiga.projects.history.choices import HistoryType +from taiga.projects.history.services import make_key_from_model_object, take_snapshot +from taiga.projects.notifications import models +from taiga.projects.notifications import services + + +def create_notification( + project, key, owner, history_entries, history_type=HistoryType.change +): + notification, created = models.HistoryChangeNotification.objects.select_for_update().get_or_create( + key=key, owner=owner, project=project, history_type=HistoryType.change + ) + notification.history_entries.add(*history_entries) + return notification + + +def create_us_context(project, owner): + us = f.UserStoryFactory.create(project=project, owner=owner) + key = make_key_from_model_object(us) + + hc1 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="test:change", + type=HistoryType.change, + key=key, + is_hidden=False, + values={"users": {}}, + diff={"description": "test:desc"}, + ) + + # not notifiable + hc2 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="", + type=HistoryType.change, + key=key, + is_hidden=False, + values={"users": {}}, + diff={"content": "test:content"}, + ) + + hc3 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="", + type=HistoryType.change, + key=key, + is_hidden=False, + values={"users": {"5": "Administrator", "11": "Angela Perez"}}, + diff={"users": {"5": "Administrator", "11": "Angela Perez"}}, + ) + + # not notifiable + hc4 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="", + type=HistoryType.change, + key=key, + is_hidden=False, + values={"users": [], "status": {"1": "New", "3": "In progress"}}, + diff={"content": "test:content"}, + ) + + take_snapshot(us, user=us.owner) + return create_notification(project, key, owner, [hc1, hc2, hc3, hc4]) + + +def create_task_context(project, owner): + task = f.TaskFactory(project=project, owner=owner) + key = make_key_from_model_object(task) + + task_history_change = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="test:change", + type=HistoryType.change, + key=key, + is_hidden=False, + diff=[], + ) + + take_snapshot(task, user=task.owner) + return create_notification(project, key, owner, [task_history_change]) + + +def create_epic_context(project, owner): + epic = f.EpicFactory.create(project=project, owner=owner) + key = make_key_from_model_object(epic) + + hc1 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="", + type=HistoryType.create, + key=key, + is_hidden=False, + diff={"description": "new description"}, + ) + + hc2 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="test: change", + type=HistoryType.change, + key=key, + is_hidden=False, + diff={"content": "change content"}, + ) + + hc3 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="", + type=HistoryType.change, + key=key, + is_hidden=False, + diff={"blocked_note": "blocked note"}, + ) + + take_snapshot(epic, user=epic.owner) + return create_notification(project, key, owner, [hc1, hc2, hc3]) + + +def create_issue_context(project, owner): + issue = f.IssueFactory.create(project=project, owner=owner) + key = make_key_from_model_object(issue) + + hc1 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="test:change", + type=HistoryType.change, + key=key, + is_hidden=False, + values={"users": {}}, + diff={"description": "test:desc"}, + ) + + # not notifiable + hc2 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="", + type=HistoryType.change, + key=key, + is_hidden=False, + values={"users": {}}, + diff={"content": "test:content"}, + ) + + hc3 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="", + type=HistoryType.change, + key=key, + is_hidden=False, + values={"users": {"5": "Administrator", "11": "Angela Perez"}}, + diff={"users": {"5": "Administrator", "11": "Angela Perez"}}, + ) + + # not notifiable + hc4 = f.HistoryEntryFactory.create( + project=project, + user={"pk": owner.id}, + comment="", + type=HistoryType.change, + key=key, + is_hidden=False, + values={"users": [], "status": {"1": "New", "3": "In progress"}}, + diff={"content": "test:content"}, + ) + + take_snapshot(issue, user=issue.owner) + return create_notification(project, key, owner, [hc1, hc2, hc3, hc4]) + + +@pytest.mark.django_db +def test_sync_send_notifications(): + settings.NOTIFICATIONS_CUSTOM_FILTER = True + project = f.ProjectFactory.create() + role = f.RoleFactory.create( + project=project, + permissions=["view_issues", "view_us", "view_tasks", "view_wiki_pages"], + ) + member = f.MembershipFactory.create(project=project, role=role) + + notification = create_epic_context(project, member.user) + sent, entries = services.send_sync_notifications(notification.id) + assert notification.id == sent + assert 2 == len(entries) + + notification = create_us_context(project, member.user) + sent, entries = services.send_sync_notifications(notification.id) + assert notification.id == sent + assert 2 == len(entries) + + notification = create_issue_context(project, member.user) + sent, entries = services.send_sync_notifications(notification.id) + assert notification.id == sent + assert 2 == len(entries) + + notification = create_task_context(project, member.user) + sent, entries = services.send_sync_notifications(notification.id) + assert not sent + assert not len(entries) + + # restore settings + settings.NOTIFICATIONS_CUSTOM_FILTER = False diff --git a/tests/integration/test_occ.py b/tests/integration/test_occ.py new file mode 100644 index 000000000..2d7755ae9 --- /dev/null +++ b/tests/integration/test_occ.py @@ -0,0 +1,349 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest.mock import patch + +from django.urls import reverse + +from taiga.base.utils import json + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_valid_us_creation(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + client.login(user) + + url = reverse("userstories-list") + data = { + 'project': project.id, + 'subject': 'test', + } + + response = client.post(url, json.dumps(data), content_type="application/json") + assert response.status_code == 201 + + +def test_invalid_concurrent_save_for_issue(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("issues-list") + data = {"subject": "test", + "project": project.id, + "status": f.IssueStatusFactory.create(project=project).id, + "severity": f.SeverityFactory.create(project=project).id, + "type": f.IssueTypeFactory.create(project=project).id, + "priority": f.PriorityFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + issue_id = response.data["id"] + url = reverse("issues-detail", args=(issue_id,)) + data = {"version": 1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 1, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + + +def test_valid_concurrent_save_for_issue_different_versions(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("issues-list") + data = {"subject": "test", + "project": project.id, + "status": f.IssueStatusFactory.create(project=project).id, + "severity": f.SeverityFactory.create(project=project).id, + "type": f.IssueTypeFactory.create(project=project).id, + "priority": f.PriorityFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + issue_id = response.data["id"] + url = reverse("issues-detail", args=(issue_id,)) + data = {"version": 1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 2, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_valid_concurrent_save_for_issue_different_fields(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("issues-list") + data = {"subject": "test", + "project": project.id, + "status": f.IssueStatusFactory.create(project=project).id, + "severity": f.SeverityFactory.create(project=project).id, + "type": f.IssueTypeFactory.create(project=project).id, + "priority": f.PriorityFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + issue_id = response.data["id"] + url = reverse("issues-detail", args=(issue_id,)) + data = {"version": 1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 1, "description": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_invalid_concurrent_save_for_wiki_page(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.wiki.api.WikiViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("wiki-list") + data = {"project": project.id, "slug": "test"} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + wiki_id = response.data["id"] + url = reverse("wiki-detail", args=(wiki_id,)) + data = {"version": 1, "content": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 1, "content": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + + +def test_valid_concurrent_save_for_wiki_page_different_versions(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.wiki.api.WikiViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("wiki-list") + data = {"project": project.id, "slug": "test"} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + wiki_id = response.data["id"] + url = reverse("wiki-detail", args=(wiki_id,)) + data = {"version": 1, "content": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 2, "content": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_invalid_concurrent_save_for_us(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + f.UserStoryFactory.create(version=10, project=project) + client.login(user) + + mock_path = "taiga.projects.userstories.api.UserStoryViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("userstories-list") + data = {"subject": "test", + "project": project.id, + "status": f.UserStoryStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + userstory_id = response.data["id"] + url = reverse("userstories-detail", args=(userstory_id,)) + data = {"version": 1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 1, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + + +def test_valid_concurrent_save_for_us_different_versions(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.userstories.api.UserStoryViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("userstories-list") + data = {"subject": "test", + "project": project.id, + "status": f.UserStoryStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + userstory_id = response.data["id"] + url = reverse("userstories-detail", args=(userstory_id,)) + data = {"version": 1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 2, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_valid_concurrent_save_for_us_different_fields(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.userstories.api.UserStoryViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("userstories-list") + data = {"subject": "test", + "project": project.id, + "status": f.UserStoryStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + userstory_id = response.data["id"] + url = reverse("userstories-detail", args=(userstory_id,)) + data = {"version": 1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 1, "description": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_invalid_concurrent_save_for_task(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("tasks-list") + data = {"subject": "test", + "project": project.id, + "status": f.TaskStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + task_id = response.data["id"] + url = reverse("tasks-detail", args=(task_id,)) + data = {"version": 1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 1, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + + +def test_valid_concurrent_save_for_task_different_versions(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("tasks-list") + data = {"subject": "test", + "project": project.id, + "status": f.TaskStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + task_id = response.data["id"] + url = reverse("tasks-detail", args=(task_id,)) + data = {"version": 1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 2, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_valid_concurrent_save_for_task_different_fields(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("tasks-list") + data = {"subject": "test", + "project": project.id, + "status": f.TaskStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + task_id = response.data["id"] + url = reverse("tasks-detail", args=(task_id,)) + data = {"version": 1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version": 1, "description": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + + +def test_invalid_save_without_version_parameter(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + client.login(user) + + mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save" + with patch(mock_path): + url = reverse("tasks-list") + data = {"subject": "test", + "project": project.id, + "status": f.TaskStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + task_id = response.data["id"] + url = reverse("tasks-detail", args=(task_id,)) + data = {"subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 diff --git a/tests/integration/test_permissions.py b/tests/integration/test_permissions.py new file mode 100644 index 000000000..f238ad207 --- /dev/null +++ b/tests/integration/test_permissions.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from taiga.permissions import services, choices +from django.contrib.auth.models import AnonymousUser + +from .. import factories + +pytestmark = pytest.mark.django_db + + +def test_get_user_project_role(): + user1 = factories.UserFactory() + user2 = factories.UserFactory() + project = factories.ProjectFactory() + role = factories.RoleFactory() + membership = factories.MembershipFactory(user=user1, project=project, role=role) + + assert services._get_user_project_membership(user1, project) == membership + assert services._get_user_project_membership(user2, project) is None + + +def test_anon_get_user_project_permissions(): + project = factories.ProjectFactory() + project.anon_permissions = ["test1"] + project.public_permissions = ["test2"] + assert services.get_user_project_permissions(AnonymousUser(), project) == set(["test1"]) + + +def test_user_get_user_project_permissions_on_public_project(): + user1 = factories.UserFactory() + project = factories.ProjectFactory() + project.anon_permissions = ["test1"] + project.public_permissions = ["test2"] + assert services.get_user_project_permissions(user1, project) == set(["test1", "test2"]) + + +def test_user_get_user_project_permissions_on_private_project(): + user1 = factories.UserFactory() + project = factories.ProjectFactory() + project.anon_permissions = ["test1"] + project.public_permissions = ["test2"] + project.is_private = True + assert services.get_user_project_permissions(user1, project) == set(["test1", "test2"]) + + +def test_owner_get_user_project_permissions(): + user1 = factories.UserFactory() + project = factories.ProjectFactory() + project.anon_permissions = ["test1"] + project.public_permissions = ["test2"] + project.owner = user1 + role = factories.RoleFactory(permissions=["view_us"]) + factories.MembershipFactory(user=user1, project=project, role=role) + + expected_perms = set( + ["test1", "test2", "view_us"] + ) + assert services.get_user_project_permissions(user1, project) == expected_perms + + +def test_owner_member_get_user_project_permissions(): + user1 = factories.UserFactory() + project = factories.ProjectFactory() + project.anon_permissions = ["test1"] + project.public_permissions = ["test2"] + role = factories.RoleFactory(permissions=["test3"]) + factories.MembershipFactory(user=user1, project=project, role=role, is_admin=True) + + expected_perms = set( + ["test1", "test2", "test3"] + + [x[0] for x in choices.ADMINS_PERMISSIONS] + + [x[0] for x in choices.MEMBERS_PERMISSIONS] + ) + assert services.get_user_project_permissions(user1, project) == expected_perms + + +def test_member_get_user_project_permissions(): + user1 = factories.UserFactory() + project = factories.ProjectFactory() + project.anon_permissions = ["test1"] + project.public_permissions = ["test2"] + role = factories.RoleFactory(permissions=["test3"]) + factories.MembershipFactory(user=user1, project=project, role=role) + + assert services.get_user_project_permissions(user1, project) == set(["test1", "test2", "test3"]) + + +def test_anon_user_has_perm(): + project = factories.ProjectFactory() + project.anon_permissions = ["test"] + assert services.user_has_perm(AnonymousUser(), "test", project) is True + assert services.user_has_perm(AnonymousUser(), "fail", project) is False + + +def test_authenticated_user_has_perm_on_project(): + user1 = factories.UserFactory() + project = factories.ProjectFactory() + project.public_permissions = ["test"] + assert services.user_has_perm(user1, "test", project) is True + assert services.user_has_perm(user1, "fail", project) is False + + +def test_authenticated_user_has_perm_on_project_related_object(): + user1 = factories.UserFactory() + project = factories.ProjectFactory() + project.public_permissions = ["test"] + us = factories.UserStoryFactory(project=project) + + assert services.user_has_perm(user1, "test", us) is True + assert services.user_has_perm(user1, "fail", us) is False + + +def test_authenticated_user_has_perm_on_invalid_object(): + user1 = factories.UserFactory() + assert services.user_has_perm(user1, "test", user1) is False diff --git a/tests/integration/test_project_settings.py b/tests/integration/test_project_settings.py new file mode 100644 index 000000000..5a9984e70 --- /dev/null +++ b/tests/integration/test_project_settings.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import json + +import pytest + +from django.apps import apps +from django.urls 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 diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py new file mode 100644 index 000000000..351462588 --- /dev/null +++ b/tests/integration/test_projects.py @@ -0,0 +1,2936 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse +from django.conf import settings +from django.core.files import File +from django.core import mail +from django.core import signing + +from taiga.base import exceptions as exc +from taiga.base.utils import json +from taiga.projects.services import stats as stats_services +from taiga.projects.history.services import take_snapshot +from taiga.permissions.choices import ANON_PERMISSIONS +from taiga.projects.models import Project, Swimlane +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task +from taiga.projects.issues.models import Issue +from taiga.projects.epics.models import Epic +from taiga.projects.choices import BLOCKED_BY_DELETING +from taiga.timeline.service import get_project_timeline + +from .. import factories as f +from ..utils import DUMMY_BMP_DATA + +from tempfile import NamedTemporaryFile +from easy_thumbnails.files import generate_all_aliases, get_thumbnailer + +import os.path +import pytest + +from unittest import mock + + +pytestmark = pytest.mark.django_db + + +class ExpiredSigner(signing.TimestampSigner): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.salt = "django.core.signing.TimestampSigner" + + def timestamp(self): + from django.utils import baseconv + import time + time_in_the_far_past = int(time.time()) - 24*60*60*1000 + return baseconv.base62.encode(time_in_the_far_past) + + +def test_get_project_detail(client): + # api/v1/projects/2 + project = f.create_project(is_private=False, + anon_permissions=['view_project'], + public_permissions=['view_project']) + url = reverse("projects-detail", kwargs={"pk": project.id}) + + # anonymous access but public project + response = client.json.get(url) + assert response.status_code == 200 + + +def test_get_wrong_project_detail_using_slug_instead_pk(client): + # api/v1/projects/project-2 (bad pk) + project = f.create_project(is_private=True) + url = reverse("projects-detail", kwargs={"pk": project.slug}) + + response = client.json.get(url) + assert response.status_code == 401 + + client.login(project.owner) + response = client.json.get(url) + assert response.status_code == 404 + + +def test_get_private_project_by_slug(client): + # api/v1/projects/by_slug?slug=project-2 + project = f.create_project(is_private=True) + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + + url = reverse("projects-by-slug") + + response = client.json.get(url, {"slug": project.slug}) + + assert response.status_code == 401 + + client.login(project.owner) + response = client.json.get(url, {"slug": project.slug}) + assert response.status_code == 200 + + +def test_create_project(client): + user = f.create_user() + url = reverse("projects-list") + data = {"name": "project name", "description": "project description"} + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + +def test_create_private_project_without_enough_private_projects_slots(client): + user = f.create_user(max_private_projects=0) + url = reverse("projects-list") + data = { + "name": "project name", + "description": "project description", + "is_private": True + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more private projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "True" + + +def test_create_public_project_without_enough_public_projects_slots(client): + user = f.create_user(max_public_projects=0) + url = reverse("projects-list") + data = { + "name": "project name", + "description": "project description", + "is_private": False + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more public projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "False" + + +def test_change_project_from_private_to_public_without_enough_public_projects_slots(client): + project = f.create_project(is_private=True, owner__max_public_projects=0) + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "is_private": False + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more public projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "False" + + +def test_change_project_from_public_to_private_without_enough_private_projects_slots(client): + project = f.create_project(is_private=False, owner__max_private_projects=0) + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "is_private": True + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more private projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "True" + + +def test_create_private_project_with_enough_private_projects_slots(client): + user = f.create_user(max_private_projects=1) + url = reverse("projects-list") + data = { + "name": "project name", + "description": "project description", + "is_private": True + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + +def test_create_public_project_with_enough_public_projects_slots(client): + user = f.create_user(max_public_projects=1) + url = reverse("projects-list") + data = { + "name": "project name", + "description": "project description", + "is_private": False + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + +def test_change_project_from_private_to_public_with_enough_public_projects_slots(client): + project = f.create_project(is_private=True, owner__max_public_projects=1) + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "is_private": False + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200 + + +def test_change_project_from_public_to_private_with_enough_private_projects_slots(client): + project = f.create_project(is_private=False, owner__max_private_projects=1) + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "is_private": True + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200 + + +def test_change_project_other_data_with_enough_private_projects_slots(client): + project = f.create_project(is_private=True, owner__max_private_projects=1) + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "name": "test-project-change" + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200 + + +def test_partially_update_project(client): + project = f.create_project() + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + data = {"name": ""} + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_us_status_is_closed_changed_recalc_us_is_closed(client): + us_status = f.UserStoryStatusFactory(is_closed=False) + user_story = f.UserStoryFactory.create(project=us_status.project, status=us_status) + + assert user_story.is_closed is False + + us_status.is_closed = True + us_status.save() + + user_story.refresh_from_db() + assert user_story.is_closed is True + + us_status.is_closed = False + us_status.save() + + user_story.refresh_from_db() + assert user_story.is_closed is False + + +def test_task_status_is_closed_changed_recalc_us_is_closed(client): + us_status = f.UserStoryStatusFactory() + user_story = f.UserStoryFactory.create(project=us_status.project, status=us_status) + task_status = f.TaskStatusFactory.create(project=us_status.project, is_closed=False) + f.TaskFactory.create(project=us_status.project, status=task_status, user_story=user_story) + + assert user_story.is_closed is False + + task_status.is_closed = True + task_status.save() + + user_story = user_story.__class__.objects.get(pk=user_story.pk) + assert user_story.is_closed is True + + task_status.is_closed = False + task_status.save() + + user_story = user_story.__class__.objects.get(pk=user_story.pk) + assert user_story.is_closed is False + + +def test_us_status_slug_generation(client): + us_status = f.UserStoryStatusFactory(name="NEW") + f.MembershipFactory(user=us_status.project.owner, project=us_status.project, is_admin=True) + assert us_status.slug == "new" + + client.login(us_status.project.owner) + + url = reverse("userstory-statuses-detail", kwargs={"pk": us_status.pk}) + + data = {"name": "new"} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["slug"] == "new" + + data = {"name": "new status"} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["slug"] == "new-status" + + +def test_task_status_slug_generation(client): + task_status = f.TaskStatusFactory(name="NEW") + f.MembershipFactory(user=task_status.project.owner, project=task_status.project, is_admin=True) + assert task_status.slug == "new" + + client.login(task_status.project.owner) + + url = reverse("task-statuses-detail", kwargs={"pk": task_status.pk}) + + data = {"name": "new"} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["slug"] == "new" + + data = {"name": "new status"} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["slug"] == "new-status" + + +def test_issue_status_slug_generation(client): + issue_status = f.IssueStatusFactory(name="NEW") + f.MembershipFactory(user=issue_status.project.owner, project=issue_status.project, is_admin=True) + assert issue_status.slug == "new" + + client.login(issue_status.project.owner) + + url = reverse("issue-statuses-detail", kwargs={"pk": issue_status.pk}) + + data = {"name": "new"} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["slug"] == "new" + + data = {"name": "new status"} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["slug"] == "new-status" + + +def test_points_name_duplicated(client): + point_1 = f.PointsFactory() + point_2 = f.PointsFactory(project=point_1.project) + f.MembershipFactory(user=point_1.project.owner, project=point_1.project, is_admin=True) + client.login(point_1.project.owner) + + url = reverse("points-detail", kwargs={"pk": point_2.pk}) + data = {"name": point_1.name} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + assert response.data["name"][0] == "Duplicated name" + + +def test_update_points_when_not_null_values_for_points(client): + points = f.PointsFactory(name="?", value="6") + f.RoleFactory(project=points.project, computable=True) + assert points.project.points.filter(value__isnull=True).count() == 0 + points.project.update_role_points() + assert points.project.points.filter(value__isnull=True).count() == 1 + + +def test_get_closed_bugs_per_member_stats(): + project = f.ProjectFactory() + membership_1 = f.MembershipFactory(project=project) + membership_2 = f.MembershipFactory(project=project) + issue_closed_status = f.IssueStatusFactory(is_closed=True, project=project) + issue_open_status = f.IssueStatusFactory(is_closed=False, project=project) + f.IssueFactory(project=project, + status=issue_closed_status, + owner=membership_1.user, + assigned_to=membership_1.user) + f.IssueFactory(project=project, + status=issue_open_status, + owner=membership_2.user, + assigned_to=membership_2.user) + task_closed_status = f.TaskStatusFactory(is_closed=True, project=project) + task_open_status = f.TaskStatusFactory(is_closed=False, project=project) + f.TaskFactory(project=project, + status=task_closed_status, + owner=membership_1.user, + assigned_to=membership_1.user) + f.TaskFactory(project=project, + status=task_open_status, + owner=membership_2.user, + assigned_to=membership_2.user) + f.TaskFactory(project=project, + status=task_open_status, + owner=membership_2.user, + assigned_to=membership_2.user, + is_iocaine=True) + + wiki_page = f.WikiPageFactory.create(project=project, owner=membership_1.user) + take_snapshot(wiki_page, user=membership_1.user) + wiki_page.content = "Frontend, future" + wiki_page.save() + take_snapshot(wiki_page, user=membership_1.user) + + stats = stats_services.get_member_stats_for_project(project) + + assert stats["closed_bugs"][membership_1.user.id] == 1 + assert stats["closed_bugs"][membership_2.user.id] == 0 + + assert stats["iocaine_tasks"][membership_1.user.id] == 0 + assert stats["iocaine_tasks"][membership_2.user.id] == 1 + + assert stats["wiki_changes"][membership_1.user.id] == 2 + assert stats["wiki_changes"][membership_2.user.id] == 0 + + assert stats["created_bugs"][membership_1.user.id] == 1 + assert stats["created_bugs"][membership_2.user.id] == 1 + + assert stats["closed_tasks"][membership_1.user.id] == 1 + assert stats["closed_tasks"][membership_2.user.id] == 0 + + +def test_leave_project_valid_membership(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + f.MembershipFactory.create(project=project, user=user, role=role) + client.login(user) + url = reverse("projects-leave", args=(project.id,)) + response = client.post(url) + assert response.status_code == 200 + + +def test_leave_project_valid_membership_only_owner(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + client.login(user) + url = reverse("projects-leave", args=(project.id,)) + response = client.post(url) + assert response.status_code == 403 + assert response.data["_error_message"] == "You can't leave the project if you are the owner or there are no more admins" + + +def test_leave_project_valid_membership_real_owner(client): + owner_user = f.UserFactory.create() + member_user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=owner_user) + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + f.MembershipFactory.create(project=project, user=owner_user, role=role, is_admin=True) + f.MembershipFactory.create(project=project, user=member_user, role=role, is_admin=True) + + client.login(owner_user) + url = reverse("projects-leave", args=(project.id,)) + response = client.post(url) + assert response.status_code == 403 + assert response.data["_error_message"] == "You can't leave the project if you are the owner or there are no more admins" + + +def test_leave_project_invalid_membership(client): + user = f.UserFactory.create() + project = f.ProjectFactory() + client.login(user) + url = reverse("projects-leave", args=(project.id,)) + response = client.post(url) + assert response.status_code == 404 + + +def test_leave_project_respect_watching_items(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + f.MembershipFactory.create(project=project, user=user, role=role) + issue = f.IssueFactory(owner=user) + issue.watchers=[user] + issue.save() + + client.login(user) + url = reverse("projects-leave", args=(project.id,)) + response = client.post(url) + assert response.status_code == 200 + assert issue.watchers == [user] + + +def test_delete_membership_only_owner(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + client.login(user) + url = reverse("memberships-detail", args=(membership.id,)) + response = client.delete(url) + assert response.status_code == 400 + assert response.data["_error_message"] == "The project must have an owner and at least one of the users must be an active admin" + + +def test_delete_membership_real_owner(client): + owner_user = f.UserFactory.create() + member_user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=owner_user) + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + owner_membership = f.MembershipFactory.create(project=project, user=owner_user, role=role, is_admin=True) + f.MembershipFactory.create(project=project, user=member_user, role=role, is_admin=True) + + client.login(owner_user) + url = reverse("memberships-detail", args=(owner_membership.id,)) + response = client.delete(url) + assert response.status_code == 400 + assert response.data["_error_message"] == "The project must have an owner and at least one of the users must be an active admin" + + +def test_edit_membership_only_owner(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + data = { + "is_admin": False + } + client.login(user) + url = reverse("memberships-detail", args=(membership.id,)) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + assert response.data["is_admin"][0] == "At least one user must be an active admin for this project." + + +def test_anon_permissions_generation_when_making_project_public(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(is_private=True) + role = f.RoleFactory.create(project=project, permissions=["view_project", "modify_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + assert project.anon_permissions == [] + client.login(user) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + data = {"is_private": False} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS)) + assert set(anon_permissions).issubset(set(response.data["anon_permissions"])) + assert set(anon_permissions).issubset(set(response.data["public_permissions"])) + + +def test_destroy_point_and_reassign(client): + project = f.ProjectFactory.create() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + p1 = f.PointsFactory(project=project) + project.default_points = p1 + project.save() + p2 = f.PointsFactory(project=project) + user_story = f.UserStoryFactory.create(project=project) + rp1 = f.RolePointsFactory.create(user_story=user_story, points=p1) + + url = reverse("points-detail", args=[p1.pk]) + "?moveTo={}".format(p2.pk) + + client.login(project.owner) + + assert user_story.role_points.all()[0].points.id == p1.id + assert project.default_points.id == p1.id + + response = client.delete(url) + + assert user_story.role_points.all()[0].points.id == p2.id + project.refresh_from_db() + assert project.default_points.id == p2.id + + +@pytest.mark.django_db(transaction=True) +def test_update_projects_order_in_bulk(client): + user = f.create_user() + client.login(user) + membership_1 = f.MembershipFactory(user=user) + membership_2 = f.MembershipFactory(user=user) + + url = reverse("projects-bulk-update-order") + data = [ + {"project_id": membership_1.project.id, "order":100}, + {"project_id": membership_2.project.id, "order":200} + ] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 204 + assert user.memberships.get(project=membership_1.project).user_order == 100 + assert user.memberships.get(project=membership_2.project).user_order == 200 + + +def test_create_and_use_template(client): + user = f.UserFactory.create(is_superuser=True) + project = f.create_project() + role = f.RoleFactory(project=project) + f.MembershipFactory(user=user, project=project, is_admin=True, role=role) + client.login(user) + + url = reverse("projects-create-template", kwargs={"pk": project.pk}) + data = { + "template_name": "test template", + "template_description": "test template description" + } + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + template_id = response.data["id"] + url = reverse("projects-list") + data = { + "name": "test project based on template", + "description": "test project based on template", + "creation_template": template_id, + } + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + +def test_projects_user_order(client): + user = f.UserFactory.create(is_superuser=True) + project_1 = f.create_project() + role_1 = f.RoleFactory(project=project_1) + f.MembershipFactory(user=user, project=project_1, is_admin=True, role=role_1, user_order=2) + + project_2 = f.create_project() + role_2 = f.RoleFactory(project=project_2) + f.MembershipFactory(user=user, project=project_2, is_admin=True, role=role_2, user_order=1) + + client.login(user) + #Testing default id order + url = reverse("projects-list") + url = "%s?member=%s" % (url, user.id) + response = client.json.get(url) + response_content = response.data + assert response.status_code == 200 + assert(response_content[0]["id"] == project_1.id) + + #Testing user order + url = reverse("projects-list") + url = "%s?member=%s&order_by=user_order" % (url, user.id) + response = client.json.get(url) + response_content = response.data + assert response.status_code == 200 + assert(response_content[0]["id"] == project_2.id) + + +@pytest.mark.django_db(transaction=True) +def test_update_project_logo(client): + user = f.UserFactory.create(is_superuser=True) + project = f.create_project() + url = reverse("projects-change-logo", args=(project.id,)) + + with NamedTemporaryFile(delete=False) as logo: + logo.write(DUMMY_BMP_DATA) + logo.seek(0) + project.logo = File(logo) + project.save() + generate_all_aliases(project.logo, include_global=True) + + thumbnailer = get_thumbnailer(project.logo) + original_photo_paths = [project.logo.path] + original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] + + assert all(list(map(os.path.exists, original_photo_paths))) + + with NamedTemporaryFile(delete=False) as logo: + logo.write(DUMMY_BMP_DATA) + logo.seek(0) + + client.login(user) + post_data = {'logo': logo} + response = client.post(url, post_data) + assert response.status_code == 200 + assert not any(list(map(os.path.exists, original_photo_paths))) + + +@pytest.mark.django_db(transaction=True) +def test_update_project_logo_with_long_file_name(client): + user = f.UserFactory.create(is_superuser=True) + project = f.create_project() + url = reverse("projects-change-logo", args=(project.id,)) + + with NamedTemporaryFile(delete=False) as logo: + logo.name=500*"x"+".bmp" + logo.write(DUMMY_BMP_DATA) + logo.seek(0) + + client.login(user) + post_data = {'logo': logo} + response = client.post(url, post_data) + + assert response.status_code == 200 + + +@pytest.mark.django_db(transaction=True) +def test_remove_project_logo(client): + user = f.UserFactory.create(is_superuser=True) + project = f.create_project() + url = reverse("projects-remove-logo", args=(project.id,)) + + with NamedTemporaryFile(delete=False) as logo: + logo.write(DUMMY_BMP_DATA) + logo.seek(0) + project.logo = File(logo) + project.save() + generate_all_aliases(project.logo, include_global=True) + + thumbnailer = get_thumbnailer(project.logo) + original_photo_paths = [project.logo.path] + original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] + + assert all(list(map(os.path.exists, original_photo_paths))) + client.login(user) + response = client.post(url) + assert response.status_code == 200 + assert not any(list(map(os.path.exists, original_photo_paths))) + +@pytest.mark.django_db(transaction=True) +def test_remove_project_with_logo(client): + user = f.UserFactory.create(is_superuser=True) + project = f.create_project() + url = reverse("projects-detail", args=(project.id,)) + + with NamedTemporaryFile(delete=False) as logo: + logo.write(DUMMY_BMP_DATA) + logo.seek(0) + project.logo = File(logo) + project.save() + generate_all_aliases(project.logo, include_global=True) + + thumbnailer = get_thumbnailer(project.logo) + original_photo_paths = [project.logo.path] + original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] + + assert all(list(map(os.path.exists, original_photo_paths))) + client.login(user) + response = client.delete(url) + assert response.status_code == 204 + assert not any(list(map(os.path.exists, original_photo_paths))) + + +def test_project_list_without_search_query_order_by_name(client): + user = f.UserFactory.create(is_superuser=True) + project3 = f.create_project(name="test 3 - word", description="description 3", tags=["tag3"]) + project1 = f.create_project(name="test 1", description="description 1 - word", tags=["tag1"]) + project2 = f.create_project(name="test 2", description="description 2", tags=["word", "tag2"]) + + url = reverse("projects-list") + + client.login(user) + response = client.json.get(url) + + assert response.status_code == 200 + assert response.data[0]["id"] == project1.id + assert response.data[1]["id"] == project2.id + assert response.data[2]["id"] == project3.id + + +def test_project_list_with_search_query_order_by_ranking(client): + user = f.UserFactory.create(is_superuser=True) + project3 = f.create_project(name="test 3 - word", description="description 3", tags=["tag3"]) + project1 = f.create_project(name="test 1", description="description 1 - word", tags=["tag1"]) + project2 = f.create_project(name="test 2", description="description 2", tags=["word", "tag2"]) + project4 = f.create_project(name="test 4", description="description 4", tags=["tag4"]) + project5 = f.create_project(name="test 5", description="description 5", tags=["tag5"]) + + url = reverse("projects-list") + + client.login(user) + response = client.json.get(url, {"q": "word"}) + + assert response.status_code == 200 + assert len(response.data) == 3 + assert response.data[0]["id"] == project3.id + assert response.data[1]["id"] == project2.id + assert response.data[2]["id"] == project1.id + + +#################################################################################### +# Test transfer project ownership +#################################################################################### + + +def test_transfer_request_from_not_anonimous(client): + user = f.UserFactory.create() + project = f.create_project(anon_permissions=["view_project"]) + + url = reverse("projects-transfer-request", args=(project.id,)) + + mail.outbox = [] + + response = client.json.post(url) + assert response.status_code == 401 + assert len(mail.outbox) == 0 + + +def test_transfer_request_from_not_project_member(client): + user = f.UserFactory.create() + project = f.create_project(public_permissions=["view_project"]) + + url = reverse("projects-transfer-request", args=(project.id,)) + + mail.outbox = [] + + client.login(user) + response = client.json.post(url) + assert response.status_code == 403 + assert len(mail.outbox) == 0 + + +def test_transfer_request_from_not_admin_member(client): + user = f.UserFactory.create() + project = f.create_project() + role = f.RoleFactory(project=project, permissions=["view_project"]) + f.create_membership(user=user, project=project, role=role, is_admin=False) + + url = reverse("projects-transfer-request", args=(project.id,)) + + mail.outbox = [] + + client.login(user) + response = client.json.post(url) + assert response.status_code == 403 + assert len(mail.outbox) == 0 + + +def test_transfer_request_from_admin_member(client): + user = f.UserFactory.create() + project = f.create_project() + role = f.RoleFactory(project=project, permissions=["view_project"]) + f.create_membership(user=user, project=project, role=role, is_admin=True) + + url = reverse("projects-transfer-request", args=(project.id,)) + + mail.outbox = [] + + client.login(user) + response = client.json.post(url) + assert response.status_code == 200 + assert len(mail.outbox) == 1 + + +def test_project_transfer_start_to_not_a_membership(client): + user_from = f.UserFactory.create() + project = f.create_project(owner=user_from) + f.create_membership(user=user_from, project=project, is_admin=True) + + client.login(user_from) + url = reverse("projects-transfer-start", kwargs={"pk": project.pk}) + + data = { + "user": 666, + } + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "The user doesn't exist" in response.data + + +def test_project_transfer_start_to_a_not_admin_member(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + project = f.create_project(owner=user_from) + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_from) + url = reverse("projects-transfer-start", kwargs={"pk": project.pk}) + + data = { + "user": user_to.id, + } + mail.outbox = [] + + assert project.transfer_token is None + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.id) + assert project.transfer_token is not None + assert len(mail.outbox) == 1 + + +def test_project_transfer_start_to_an_admin_member(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + project = f.create_project(owner=user_from) + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project, is_admin=True) + + client.login(user_from) + url = reverse("projects-transfer-start", kwargs={"pk": project.pk}) + + data = { + "user": user_to.id, + } + mail.outbox = [] + + assert project.transfer_token is None + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.id) + assert project.transfer_token is not None + assert len(mail.outbox) == 1 + + +def test_project_transfer_reject_from_member_without_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = {} + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + + +def test_project_transfer_reject_from_member_with_invalid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + project = f.create_project(owner=user_from, transfer_token="invalid-token") + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": "invalid-token", + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_reject_from_member_with_other_user_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + other_user = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(other_user.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_reject_from_member_with_expired_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = ExpiredSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token has expired" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_reject_from_admin_member_with_valid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project, is_admin=True) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [user_from.email] + project = Project.objects.get(pk=project.pk) + assert project.owner.id == user_from.id + assert project.transfer_token is None + + +def test_project_transfer_reject_from_no_admin_member_with_valid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + m = f.create_membership(user=user_to, project=project, is_admin=False) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [user_from.email] + assert m.is_admin == False + project = Project.objects.get(pk=project.pk) + m = project.memberships.get(user=user_to) + assert project.owner.id == user_from.id + assert project.transfer_token is None + assert m.is_admin == False + + +def test_project_transfer_accept_from_member_without_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = {} + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + + +def test_project_transfer_accept_from_member_with_invalid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + project = f.create_project(owner=user_from, transfer_token="invalid-token") + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": "invalid-token", + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_accept_from_member_with_other_user_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + other_user = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(other_user.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_accept_from_member_with_expired_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = ExpiredSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token has expired" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_accept_from_member_with_valid_token_without_enough_slots(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_private_projects=0) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=True) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + project = Project.objects.get(pk=project.pk) + assert project.owner.id == user_from.id + assert project.transfer_token is not None + + +def test_project_transfer_accept_from_member_with_valid_token_without_enough_memberships_public_project_slots(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_memberships_public_projects=5) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=False) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + f.create_membership(project=project) + f.create_membership(project=project) + f.create_membership(project=project, user=None, email="test_1@email.com") + f.create_membership(project=project, user=None, email="test_2@email.com") + f.create_membership(project=project, user=None, email="test_3@email.com") + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + project = Project.objects.get(pk=project.pk) + assert project.owner.id == user_from.id + assert project.transfer_token is not None + + +def test_project_transfer_accept_from_member_with_valid_token_without_enough_memberships_private_project_slots(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_memberships_private_projects=5) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=True) + project2 = f.create_project(owner=user_to, is_private=True) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + f.create_membership(project=project) + f.create_membership(project=project, user=None, email="test_1@email.com") + + f.create_membership(user=user_from, project=project2, is_admin=True) + f.create_membership(user=user_to, project=project2) + f.create_membership(project=project2) + f.create_membership(project=project2) + f.create_membership(project=project2, user=None, email="test_1@email.com") + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + project = Project.objects.get(pk=project.pk) + assert project.owner.id == user_from.id + assert project.transfer_token is not None + + +def test_project_transfer_accept_from_admin_member_with_valid_token_with_enough_slots(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_private_projects=3) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=True) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project, is_admin=True) + f.create_membership(user=None, project=project, email="test_1@email.com") + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [user_from.email] + project = Project.objects.get(pk=project.pk) + assert project.owner.id == user_to.id + assert project.transfer_token is None + + +def test_project_transfer_accept_from_no_admin_member_with_valid_token_with_enough_slots(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_private_projects=1) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=True) + + f.create_membership(user=user_from, project=project, is_admin=True) + m = f.create_membership(user=user_to, project=project, is_admin=False) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [user_from.email] + assert m.is_admin == False + project = Project.objects.get(pk=project.pk) + m = project.memberships.get(user=user_to) + assert project.owner.id == user_to.id + assert project.transfer_token is None + assert m.is_admin == True + + +def test_project_transfer_validate_token_from_member_without_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-validate-token", kwargs={"pk": project.pk}) + + data = {} + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + + +def test_project_transfer_validate_token_from_member_with_invalid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + project = f.create_project(owner=user_from, transfer_token="invalid-token") + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-validate-token", kwargs={"pk": project.pk}) + + data = { + "token": "invalid-token", + } + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + + +def test_project_transfer_validate_token_from_member_with_other_user_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + other_user = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(other_user.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-validate-token", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + + +def test_project_transfer_validate_token_from_member_with_expired_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = ExpiredSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project) + + client.login(user_to) + url = reverse("projects-transfer-validate-token", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token has expired" == response.data["_error_message"] + + + +def test_project_transfer_validate_token_from_admin_member_with_valid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_private_projects=1) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=True) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project, is_admin=True) + + client.login(user_to) + url = reverse("projects-transfer-validate-token", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + + +def test_project_transfer_validate_token_from_no_admin_member_with_valid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_private_projects=1) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=True) + + f.create_membership(user=user_from, project=project, is_admin=True) + f.create_membership(user=user_to, project=project, is_admin=False) + + client.login(user_to) + url = reverse("projects-transfer-validate-token", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + + +#################################################################################### +# Test taiga.projects.services.projects.check_if_project_privacy_can_be_changed +#################################################################################### + +from taiga.projects.services.projects import ( + check_if_project_privacy_can_be_changed, + ERROR_MAX_PUBLIC_PROJECTS_MEMBERSHIPS, + ERROR_MAX_PUBLIC_PROJECTS, + ERROR_MAX_PRIVATE_PROJECTS_MEMBERSHIPS, + ERROR_MAX_PRIVATE_PROJECTS +) + +# private to public + +def test_private_project_cant_be_public_because_owner_doesnt_have_enough_slot_and_too_much_members(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 0 + project.owner.max_memberships_public_projects = 3 + + assert (check_if_project_privacy_can_be_changed(project) == + {'can_be_updated': False, 'reason': ERROR_MAX_PUBLIC_PROJECTS_MEMBERSHIPS}) + + +def test_private_project_cant_be_public_because_owner_doesnt_have_enough_slot(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 0 + project.owner.max_memberships_public_projects = 6 + + assert (check_if_project_privacy_can_be_changed(project) == + {'can_be_updated': False, 'reason': ERROR_MAX_PUBLIC_PROJECTS}) + + +def test_private_project_cant_be_public_because_too_much_members(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 2 + project.owner.max_memberships_public_projects = 3 + + assert (check_if_project_privacy_can_be_changed(project) == + {'can_be_updated': False, 'reason': ERROR_MAX_PUBLIC_PROJECTS_MEMBERSHIPS}) + + +def test_private_project_can_be_public_because_owner_has_enough_slot_and_project_has_enough_members(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 2 + project.owner.max_memberships_public_projects = 6 + + assert (check_if_project_privacy_can_be_changed(project) == {'can_be_updated': True, 'reason': None}) + + +def test_private_project_can_be_public_because_owner_has_unlimited_slot_and_project_has_unlimited_members(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = None + project.owner.max_memberships_public_projects = None + + assert (check_if_project_privacy_can_be_changed(project) == {'can_be_updated': True, 'reason': None}) + + +def test_private_project_can_be_public_because_owner_has_unlimited_slot(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = None + project.owner.max_memberships_public_projects = 6 + + assert (check_if_project_privacy_can_be_changed(project) == {'can_be_updated': True, 'reason': None}) + + +def test_private_project_can_be_public_because_project_has_unlimited_members(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 2 + project.owner.max_memberships_public_projects = None + + assert (check_if_project_privacy_can_be_changed(project) == {'can_be_updated': True, 'reason': None}) + + +# public to private + +def test_public_project_cant_be_private_because_owner_doesnt_have_enough_slot_and_too_much_members(client): + project = f.create_project(is_private=False) + project2 = f.create_project(is_private=True, owner=project.owner) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project2, user=project.owner) + f.MembershipFactory(project=project2) + f.MembershipFactory(project=project2) + + project.owner.max_private_projects = 2 + project.owner.max_memberships_private_projects = 3 + + assert (check_if_project_privacy_can_be_changed(project) == + {'can_be_updated': False, 'reason': ERROR_MAX_PRIVATE_PROJECTS_MEMBERSHIPS}) + + +def test_public_project_cant_be_private_because_owner_doesnt_have_enough_slot(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = 0 + project.owner.max_memberships_private_projects = 6 + + assert (check_if_project_privacy_can_be_changed(project) == + {'can_be_updated': False, 'reason': ERROR_MAX_PRIVATE_PROJECTS}) + + +def test_public_project_cant_be_private_because_too_much_members(client): + project = f.create_project(is_private=False) + project2 = f.create_project(is_private=True, owner=project.owner) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project2, user=project.owner) + f.MembershipFactory(project=project2) + f.MembershipFactory(project=project2) + + project.owner.max_private_projects = 2 + project.owner.max_memberships_private_projects = 3 + + assert (check_if_project_privacy_can_be_changed(project) == + {'can_be_updated': False, 'reason': ERROR_MAX_PRIVATE_PROJECTS_MEMBERSHIPS}) + + +def test_public_project_can_be_private_because_owner_has_enough_slot_and_project_has_enough_members(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = 2 + project.owner.max_memberships_private_projects = 6 + + assert (check_if_project_privacy_can_be_changed(project) == {'can_be_updated': True, 'reason': None}) + + +def test_public_project_can_be_private_because_owner_has_unlimited_slot_and_project_has_unlimited_members(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = None + project.owner.max_memberships_private_projects = None + + assert (check_if_project_privacy_can_be_changed(project) == {'can_be_updated': True, 'reason': None}) + + +def test_public_project_can_be_private_because_owner_has_unlimited_slot(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = None + project.owner.max_memberships_private_projects = 6 + + assert (check_if_project_privacy_can_be_changed(project) == {'can_be_updated': True, 'reason': None}) + + +def test_public_project_can_be_private_because_project_has_unlimited_members(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = 2 + project.owner.max_memberships_private_projects = None + + assert (check_if_project_privacy_can_be_changed(project) == {'can_be_updated': True, 'reason': None}) + + +#################################################################################### +# test taiga.projects.services.projects.check_if_project_is_out_of_owner_limit +#################################################################################### + +from taiga.projects.services.projects import check_if_project_is_out_of_owner_limits + +def test_private_project_when_owner_doesnt_have_enough_slot_and_too_much_members(client): + project = f.create_project(is_private=True) + project2 = f.create_project(is_private=True, owner=project.owner) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project2, user=project.owner) + f.MembershipFactory(project=project2) + f.MembershipFactory(project=project2) + + project.owner.max_private_projects = 0 + project.owner.max_memberships_private_projects = 3 + + assert check_if_project_is_out_of_owner_limits(project) == True + + +def test_private_project_when_owner_doesnt_have_enough_slot(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = 0 + project.owner.max_memberships_private_projects = 6 + + assert check_if_project_is_out_of_owner_limits(project) == True + + +def test_private_project_when_too_much_members(client): + project = f.create_project(is_private=True) + project2 = f.create_project(is_private=True, owner=project.owner) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project2, user=project.owner) + f.MembershipFactory(project=project2) + f.MembershipFactory(project=project2) + + project.owner.max_private_projects = 2 + project.owner.max_memberships_private_projects = 3 + + assert check_if_project_is_out_of_owner_limits(project) == True + + +def test_private_project_when_owner_has_enough_slot_and_project_has_enough_members(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = 2 + project.owner.max_memberships_private_projects = 6 + + assert check_if_project_is_out_of_owner_limits(project) == False + + +def test_private_project_when_owner_has_unlimited_slot_and_project_has_unlimited_members(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = None + project.owner.max_memberships_private_projects = None + + assert check_if_project_is_out_of_owner_limits(project) == False + + +def test_private_project_when_owner_has_unlimited_slot(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = None + project.owner.max_memberships_private_projects = 6 + + assert check_if_project_is_out_of_owner_limits(project) == False + + +def test_private_project_when_project_has_unlimited_members(client): + project = f.create_project(is_private=True) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_private_projects = 2 + project.owner.max_memberships_private_projects = None + + assert check_if_project_is_out_of_owner_limits(project) == False + + +# public + +def test_public_project_when_owner_doesnt_have_enough_slot_and_too_much_members(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 0 + project.owner.max_memberships_public_projects = 3 + + assert check_if_project_is_out_of_owner_limits(project) == True + + +def test_public_project_when_owner_doesnt_have_enough_slot(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 0 + project.owner.max_memberships_public_projects = 6 + + assert check_if_project_is_out_of_owner_limits(project) == True + + +def test_public_project_when_too_much_members(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 2 + project.owner.max_memberships_public_projects = 3 + + assert check_if_project_is_out_of_owner_limits(project) == True + + +def test_public_project_when_owner_has_enough_slot_and_project_has_enough_members(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 2 + project.owner.max_memberships_public_projects = 6 + + assert check_if_project_is_out_of_owner_limits(project) == False + + +def test_public_project_when_owner_has_unlimited_slot_and_project_has_unlimited_members(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = None + project.owner.max_memberships_public_projects = None + + assert check_if_project_is_out_of_owner_limits(project) == False + + +def test_public_project_when_owner_has_unlimited_slot(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = None + project.owner.max_memberships_public_projects = 6 + + assert check_if_project_is_out_of_owner_limits(project) == False + + +def test_public_project_when_project_has_unlimited_members(client): + project = f.create_project(is_private=False) + f.MembershipFactory(project=project, user=project.owner) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + project.owner.max_public_projects = 2 + project.owner.max_memberships_public_projects = None + + assert check_if_project_is_out_of_owner_limits(project) == False + + +#################################################################################### +# test project deletion +#################################################################################### + +def test_delete_project_with_celery_enabled(client, settings): + settings.CELERY_ENABLED = True + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-detail", args=(project.id,)) + client.login(user) + + #delete_project task should have been launched + with mock.patch('taiga.projects.services.delete_project') as delete_project_mock: + response = client.json.delete(url) + assert response.status_code == 204 + project = Project.objects.get(id=project.id) + assert project.owner == None + assert project.memberships.count() == 0 + assert project.blocked_code == BLOCKED_BY_DELETING + delete_project_mock.delay.assert_called_once_with(project.id) + settings.CELERY_ENABLED = False + + +def test_delete_project_with_celery_disabled(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-detail", args=(project.id,)) + client.login(user) + response = client.json.delete(url) + assert response.status_code == 204 + assert Project.objects.filter(id=project.id).count() == 0 + + +#################################################################################### +# test project tags +#################################################################################### + +def test_create_tag(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-create-tag", args=(project.id,)) + client.login(user) + data = { + "tag": "newtag", + "color": "#123123" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [["newtag", "#123123"]] + + +def test_create_tag_without_color(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-create-tag", args=(project.id,)) + client.login(user) + data = { + "tag": "newtag", + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors[0][0] == "newtag" + + +def test_edit_tag_only_name(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag'1", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag'1"]) + task = f.TaskFactory.create(project=project, tags=["tag'1"]) + issue = f.IssueFactory.create(project=project, tags=["tag'1"]) + epic = f.EpicFactory.create(project=project, tags=["tag'1"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-edit-tag", args=(project.id,)) + client.login(user) + data = { + "from_tag": "tag'1", + "to_tag": "renamed_tag'1" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [["renamed_tag'1", "#123123"]] + user_story = UserStory.objects.get(id=user_story.pk) + assert user_story.tags == ["renamed_tag'1"] + task = Task.objects.get(id=task.pk) + assert task.tags == ["renamed_tag'1"] + issue = Issue.objects.get(id=issue.pk) + assert issue.tags == ["renamed_tag'1"] + epic = Epic.objects.get(id=epic.pk) + assert epic.tags == ["renamed_tag'1"] + + +def test_edit_tag_only_color(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag"]) + task = f.TaskFactory.create(project=project, tags=["tag"]) + issue = f.IssueFactory.create(project=project, tags=["tag"]) + epic = f.EpicFactory.create(project=project, tags=["tag"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-edit-tag", args=(project.id,)) + client.login(user) + data = { + "from_tag": "tag", + "color": "#AAABBB" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [["tag", "#AAABBB"]] + user_story = UserStory.objects.get(id=user_story.pk) + assert user_story.tags == ["tag"] + task = Task.objects.get(id=task.pk) + assert task.tags == ["tag"] + issue = Issue.objects.get(id=issue.pk) + assert issue.tags == ["tag"] + epic = Epic.objects.get(id=epic.pk) + assert epic.tags == ["tag"] + + +def test_edit_tag(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag"]) + task = f.TaskFactory.create(project=project, tags=["tag"]) + issue = f.IssueFactory.create(project=project, tags=["tag"]) + epic = f.EpicFactory.create(project=project, tags=["tag"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-edit-tag", args=(project.id,)) + client.login(user) + data = { + "from_tag": "tag", + "to_tag": "renamed_tag", + "color": "#AAABBB" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [["renamed_tag", "#AAABBB"]] + user_story = UserStory.objects.get(id=user_story.pk) + assert user_story.tags == ["renamed_tag"] + task = Task.objects.get(id=task.pk) + assert task.tags == ["renamed_tag"] + issue = Issue.objects.get(id=issue.pk) + assert issue.tags == ["renamed_tag"] + epic = Epic.objects.get(id=epic.pk) + assert epic.tags == ["renamed_tag"] + + +def test_delete_tag(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag'1", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag'1"]) + task = f.TaskFactory.create(project=project, tags=["tag'1"]) + issue = f.IssueFactory.create(project=project, tags=["tag'1"]) + epic = f.EpicFactory.create(project=project, tags=["tag'1"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-delete-tag", args=(project.id,)) + client.login(user) + data = { + "tag": "tag'1" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert project.tags_colors == [] + user_story = UserStory.objects.get(id=user_story.pk) + assert user_story.tags == [] + task = Task.objects.get(id=task.pk) + assert task.tags == [] + issue = Issue.objects.get(id=issue.pk) + assert issue.tags == [] + epic = Epic.objects.get(id=epic.pk) + assert epic.tags == [] + + +def test_mix_tags(client, settings): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, tags_colors=[("tag'1", "#123123"), ("tag2", "#123123"), ("tag3", "#123123")]) + user_story = f.UserStoryFactory.create(project=project, tags=["tag'1", "tag3"]) + task = f.TaskFactory.create(project=project, tags=["tag2", "tag3"]) + issue = f.IssueFactory.create(project=project, tags=["tag'1", "tag2", "tag3"]) + epic = f.EpicFactory.create(project=project, tags=["tag'1", "tag2", "tag3"]) + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + url = reverse("projects-mix-tags", args=(project.id,)) + client.login(user) + data = { + "from_tags": ["tag'1", "tag2"], + "to_tag": "tag2" + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.pk) + assert set(["tag2", "tag3"]) == set(dict(project.tags_colors).keys()) + user_story = UserStory.objects.get(id=user_story.pk) + assert set(user_story.tags) == set(["tag2", "tag3"]) + task = Task.objects.get(id=task.pk) + assert set(task.tags) == set(["tag2", "tag3"]) + issue = Issue.objects.get(id=issue.pk) + assert set(issue.tags) == set(["tag2", "tag3"]) + epic = Epic.objects.get(id=epic.pk) + assert set(epic.tags) == set(["tag2", "tag3"]) + + +def test_color_tags_project_fired_on_element_create(): + user_story = f.UserStoryFactory.create(tags=["tag"]) + project = Project.objects.get(id=user_story.project.id) + assert project.tags_colors == [["tag", None]] + + +def test_color_tags_project_fired_on_element_update(): + user_story = f.UserStoryFactory.create() + user_story.tags = ["tag"] + user_story.save() + project = Project.objects.get(id=user_story.project.id) + assert ["tag", None] in project.tags_colors + + +def test_color_tags_project_fired_on_element_update_respecting_color(): + project = f.ProjectFactory.create(tags_colors=[["tag", "#123123"]]) + user_story = f.UserStoryFactory.create(project=project) + user_story.tags = ["tag"] + user_story.save() + project = Project.objects.get(id=user_story.project.id) + assert ["tag", "#123123"] in project.tags_colors + + +#################################################################################### +# test project duplication +#################################################################################### + +def test_duplicate_project(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create( + owner=user, + is_looking_for_people=True, + looking_for_people_note="Looking lookin", + ) + project.tags = ["tag1", "tag2"] + project.tags_colors = [["t1", "#abcbca"], ["t2", "#aaabbb"]] + + project.default_epic_status = f.EpicStatusFactory.create(project=project) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_points = f.PointsFactory.create(project=project) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + + f.EpicCustomAttributeFactory(project=project) + f.UserStoryCustomAttributeFactory(project=project) + f.TaskCustomAttributeFactory(project=project) + f.IssueCustomAttributeFactory(project=project) + + project.save() + + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + f.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + extra_membership = f.MembershipFactory.create(project=project, is_admin=True, role__project=project) + membership = f.MembershipFactory.create(project=project, role=role) + url = reverse("projects-duplicate", args=(project.id,)) + + data = { + "name": "test", + "description": "description", + "is_private": True, + "users": [{ + "id": extra_membership.user.id + }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + new_project = Project.objects.get(id=response.data["id"]) + + assert new_project.owner_id == user.id + owner_membership = new_project.memberships.get(user_id=user.id) + assert owner_membership.user_id == user.id + assert owner_membership.is_admin == True + assert project.memberships.get(user_id=extra_membership.user.id).role.slug == extra_membership.role.slug + assert set(project.tags) == set(new_project.tags) + assert set(dict(project.tags_colors).keys()) == set(dict(new_project.tags_colors).keys()) + + attributes = [ + "is_epics_activated", "is_backlog_activated", "is_kanban_activated", "is_wiki_activated", + "is_issues_activated", "videoconferences", "videoconferences_extra_data", + "is_looking_for_people", "looking_for_people_note", "is_private" + ] + + for attr in attributes: + assert getattr(project, attr) == getattr(new_project, attr) + + fk_attributes = [ + "default_epic_status", "default_us_status", "default_task_status", "default_issue_status", + "default_issue_type", "default_points", "default_priority", "default_severity", + ] + + for attr in fk_attributes: + assert getattr(project, attr).name == getattr(new_project, attr).name + + related_attributes = [ + "epic_statuses", "us_statuses", "task_statuses","issue_statuses", + "issue_types", "points", "priorities", "severities", + "epiccustomattributes", "userstorycustomattributes", "taskcustomattributes", "issuecustomattributes", + "roles" + ] + for attr in related_attributes: + from_names = set(getattr(project, attr).all().values_list("name", flat=True)) + to_names = set(getattr(new_project, attr).all().values_list("name", flat=True)) + assert from_names == to_names + + timeline = list(get_project_timeline(new_project)) + assert len(timeline) == 2 + assert timeline[0].event_type == "projects.project.create" + assert timeline[1].event_type == "projects.membership.create" + + +def test_duplicate_private_project_without_enough_private_projects_slots(client): + user = f.UserFactory.create(max_private_projects=0) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(user=user, project=project, is_admin=True) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": True, + "users": [] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "can't have more private projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "True" + + +def test_duplicate_private_project_without_enough_memberships_slots(client): + user = f.UserFactory.create(max_memberships_private_projects=1) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(user=user, project=project, is_admin=True, role__project=project) + extra_membership = f.MembershipFactory(project=project, role__project=project) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": True, + "users": [{ + "id": extra_membership.user_id + }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "current limit of memberships" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "2" + assert response["Taiga-Info-Project-Is-Private"] == "True" + + +def test_duplicate_private_project_without_enough_memberships_slots_for_existen_projects(client): + user = f.UserFactory.create(max_memberships_private_projects=3) + project = f.ProjectFactory.create() + extra_membership = f.MembershipFactory(user=project.owner, project=project, is_admin=True) + f.MembershipFactory(user=user, project=project, is_admin=True) + project2 = f.ProjectFactory.create(owner=user, is_private=True) + f.MembershipFactory(user=user, project=project2, is_admin=True) + f.MembershipFactory(project=project2) + f.MembershipFactory(project=project2) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": True, + "users": [{ + "id": extra_membership.user_id + }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "current limit of memberships" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "4" + assert response["Taiga-Info-Project-Is-Private"] == "True" + + +def test_duplicate_public_project_without_enough_public_projects_slots(client): + user = f.UserFactory.create(max_public_projects=0) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(user=user, project=project, is_admin=True) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": False, + "users": [] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "can't have more public projects" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "1" + assert response["Taiga-Info-Project-Is-Private"] == "False" + + +def test_duplicate_public_project_without_enough_memberships_slots(client): + user = f.UserFactory.create(max_memberships_public_projects=1) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(user=user, project=project, is_admin=True, role__project=project) + extra_membership = f.MembershipFactory(project=project, role__project=project) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": False, + "users": [ + {"id": extra_membership.user_id}, + ] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "current limit of memberships" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "2" + assert response["Taiga-Info-Project-Is-Private"] == "False" + + +def test_duplicate_public_project_without_enough_memberships_slots_for_existen_projects(client): + user = f.UserFactory.create(max_memberships_public_projects=3) + project = f.ProjectFactory.create() + extra_membership = f.MembershipFactory(user=project.owner, project=project, is_admin=True) + f.MembershipFactory(user=user, project=project, is_admin=True) + project2 = f.ProjectFactory.create(owner=user, is_private=False) + f.MembershipFactory(user=user, project=project2, is_admin=True) + f.MembershipFactory(project=project2) + f.MembershipFactory(project=project2) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": False, + "users": [{ + "id": extra_membership.user_id + }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "current limit of memberships" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "4" + assert response["Taiga-Info-Project-Is-Private"] == "False" + + +def test_duplicate_public_project_without_enough_memberships_slots_for_existen_projects_with_invitations(client): + user = f.UserFactory.create(max_memberships_public_projects=3) + project = f.ProjectFactory.create() + extra_membership = f.MembershipFactory(user=project.owner, project=project, is_admin=True) + f.MembershipFactory(user=user, project=project, is_admin=True) + f.MembershipFactory(project=project, email="test@email.com", user=None) + project2 = f.ProjectFactory.create(owner=user, is_private=False) + f.MembershipFactory(user=user, project=project2, is_admin=True) + f.MembershipFactory(project=project2) + f.MembershipFactory(project=project2, email="test@email.com", user=None) + + url = reverse("projects-duplicate", args=(project.id,)) + data = { + "name": "test", + "description": "description", + "is_private": False, + "users": [{ + "id": extra_membership.user_id + }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "current limit of memberships" in response.data["_error_message"] + assert response["Taiga-Info-Project-Memberships"] == "4" + assert response["Taiga-Info-Project-Is-Private"] == "False" + + +#################################################################################### +# test due dates +#################################################################################### + +# User story + +def test_create_us_default_due_dates(client): + project = f.create_project() + + us_duedates = [{ + "name": 'Default', + "by_default": True, + 'color': '#0000', + 'days_to_due': None, + 'order': 0, + }] + creation_template = project.creation_template + creation_template.us_duedates = us_duedates + creation_template.save() + + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse('userstory-due-dates-create-default') + data = {"project_id": project.pk} + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert project.us_duedates.count() == 1 + + +def test_prevent_create_us_default_due_dates_when_already_exists(client): + us_duedates = [{ + "name": 'Default', + "by_default": True, + 'color': '#0000', + 'days_to_due': None, + 'order': 0, + }] + f.ProjectTemplateFactory.create( + slug=settings.DEFAULT_PROJECT_TEMPLATE, us_duedates=us_duedates) + project = f.create_project() + + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse('userstory-due-dates-create-default') + data = {"project_id": project.pk} + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert project.us_duedates.count() == 1 + + +def test_prevent_delete_us_default_due_dates(client): + us_duedates = [{ + "name": 'Default', + "by_default": True, + 'color': '#0000', + 'days_to_due': None, + 'order': 0, + }] + f.ProjectTemplateFactory.create( + slug=settings.DEFAULT_PROJECT_TEMPLATE, us_duedates=us_duedates) + project = f.create_project() + + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse('userstory-due-dates-detail', + kwargs={"pk": project.us_duedates.last().pk}) + client.login(project.owner) + + response = client.json.delete(url) + + assert response.status_code == 400 + assert project.us_duedates.count() == 1 + + +# Task + +def test_create_task_default_due_dates(client): + project = f.create_project() + + task_duedates = [{ + "name": 'Default', + "by_default": True, + 'color': '#0000', + 'days_to_due': None, + 'order': 0, + }] + creation_template = project.creation_template + creation_template.task_duedates = task_duedates + creation_template.save() + + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse('task-due-dates-create-default') + data = {"project_id": project.pk} + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert project.task_duedates.count() == 1 + + +def test_prevent_create_task_default_due_dates_when_already_exists(client): + task_duedates = [{ + "name": 'Default', + "by_default": True, + 'color': '#0000', + 'days_to_due': None, + 'order': 0, + }] + f.ProjectTemplateFactory.create( + slug=settings.DEFAULT_PROJECT_TEMPLATE, task_duedates=task_duedates) + project = f.create_project() + + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse('task-due-dates-create-default') + data = {"project_id": project.pk} + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert project.task_duedates.count() == 1 + + +def test_prevent_delete_task_default_due_dates(client): + task_duedates = [{ + "name": 'Default', + "by_default": True, + 'color': '#0000', + 'days_to_due': None, + 'order': 0, + }] + f.ProjectTemplateFactory.create( + slug=settings.DEFAULT_PROJECT_TEMPLATE, task_duedates=task_duedates) + project = f.create_project() + + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse('task-due-dates-detail', + kwargs={"pk": project.task_duedates.last().pk}) + client.login(project.owner) + + response = client.json.delete(url) + + assert response.status_code == 400 + assert project.task_duedates.count() == 1 + + +# Issue + +def test_create_issue_default_due_dates(client): + project = f.create_project() + + issue_duedates = [{ + "name": 'Default', + "by_default": True, + 'color': '#0000', + 'days_to_due': None, + 'order': 0, + }] + creation_template = project.creation_template + creation_template.issue_duedates = issue_duedates + creation_template.save() + + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse('issue-due-dates-create-default') + data = {"project_id": project.pk} + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert project.issue_duedates.count() == 1 + + +def test_prevent_create_issue_default_due_dates_when_already_exists(client): + issue_duedates = [{ + "name": 'Default', + "by_default": True, + 'color': '#0000', + 'days_to_due': None, + 'order': 0, + }] + f.ProjectTemplateFactory.create( + slug=settings.DEFAULT_PROJECT_TEMPLATE, issue_duedates=issue_duedates) + project = f.create_project() + + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse('issue-due-dates-create-default') + data = {"project_id": project.pk} + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert project.issue_duedates.count() == 1 + + +def test_prevent_delete_issue_default_due_dates(client): + issue_duedates = [{ + "name": 'Default', + "by_default": True, + 'color': '#0000', + 'days_to_due': None, + 'order': 0, + }] + f.ProjectTemplateFactory.create( + slug=settings.DEFAULT_PROJECT_TEMPLATE, issue_duedates=issue_duedates) + project = f.create_project() + + f.MembershipFactory(user=project.owner, project=project, is_admin=True) + url = reverse('issue-due-dates-detail', + kwargs={"pk": project.issue_duedates.last().pk}) + client.login(project.owner) + + response = client.json.delete(url) + + assert response.status_code == 400 + assert project.issue_duedates.count() == 1 + + +#################################################################################### +# test swimlanes +#################################################################################### + +def test_create_first_swimlane_and_assign_to_uss(client): + project = f.create_project() + membership = f.create_membership(project=project, is_admin=True) + us = f.create_userstory(owner=membership.user, + project=project) + assert us.swimlane is None + assert project.default_swimlane is None + + url = reverse('swimlanes-list') + data = { + "project": project.id, + "name": "Swimlane 1" + } + client.login(membership.user) + response = client.json.post(url, json.dumps(data)) + us.refresh_from_db() + project.refresh_from_db() + + assert response.status_code == 201 + assert project.swimlanes.count() == 1 + assert project.default_swimlane_id == us.swimlane_id + assert us.swimlane is not None + + +def test_create_second_swimlane(client): + # given a first swimlane related to a project + project = f.create_project() + membership = f.create_membership(project=project, is_admin=True) + us = f.create_userstory(owner=membership.user, + project=project) + + url = reverse('swimlanes-list') + data = { + "project": project.id, + "name": "Swimlane 1" + } + client.login(membership.user) + response = client.json.post(url, json.dumps(data)) + swimlane_1_id = json.loads(response.content)['id'] + + project.refresh_from_db() + assert project.default_swimlane_id == swimlane_1_id + + # when: create second swimlane + data = { + "project": project.id, + "name": "Swimlane 2" + } + client.login(membership.user) + response = client.json.post(url, json.dumps(data)) + swimlane_2_id = json.loads(response.content)['id'] + swimlane_2 = Swimlane.objects.get(pk=swimlane_2_id) + + us.refresh_from_db() + project.refresh_from_db() + + # then + assert response.status_code == 201 + assert project.swimlanes.count() == 2 + assert project.default_swimlane_id == swimlane_1_id + assert swimlane_2.user_stories.count() == 0 + assert us.swimlane.id == swimlane_1_id + + +def test_swimlane_bulk_update_order(client): + project = f.create_project() + membership = f.create_membership(project=project, is_admin=True) + us1 = f.create_userstory(subject='us1', + owner=membership.user, + project=project) + us2 = f.create_userstory(subject='us2', + owner=membership.user, + project=project) + + url = reverse('swimlanes-list') + data = { + "project": project.id, + "name": "S1" + } + client.login(membership.user) + response = client.json.post(url, json.dumps(data)) + + project.refresh_from_db() + swimlane1 = project.swimlanes.filter(name='S1').first() + + # both uss now belong to the first new swimlane + assert swimlane1.user_stories.count() == 2 + + # us without swimlane + us3 = f.create_userstory(subject='us3', + owner=membership.user, + project=project) + data = { + "project": project.id, + "name": "S2" + } + client.login(membership.user) + response = client.json.post(url, json.dumps(data)) + + project.refresh_from_db() + swimlane2 = project.swimlanes.filter(name='S2').first() + us4 = f.create_userstory(subject='us4', + owner=membership.user, + project=project, + swimlane=swimlane2) + us5 = f.create_userstory(subject='us5', + owner=membership.user, + project=project, + swimlane=swimlane2) + + # both new user stories belong to the second swimlane + assert swimlane2.user_stories.count() == 2 + + # In this moment, they are arranged like: + # us1, us2, us4, us5, us3 + project.refresh_from_db() + ordered_uss = project.user_stories.all().order_by('swimlane__order', 'kanban_order') + + assert ordered_uss[0].subject == 'us1' + assert ordered_uss[1].subject == 'us2' + assert ordered_uss[2].subject == 'us4' + assert ordered_uss[3].subject == 'us5' + assert ordered_uss[4].subject == 'us3' + + # After arranging the swimlanes, they should be like: + # us4, us5, us1, us2, us3 + url = reverse('swimlanes-bulk-update-order') + data = { + "project": project.id, + "bulk_swimlanes": [ + [swimlane2.id, 0], + [swimlane1.id, 1], + ] + } + response = client.json.post(url, json.dumps(data)) + + project.refresh_from_db() + ordered_uss = project.user_stories.all().order_by('swimlane__order', 'kanban_order') + + assert ordered_uss[0].subject == 'us4' + assert ordered_uss[1].subject == 'us5' + assert ordered_uss[2].subject == 'us1' + assert ordered_uss[3].subject == 'us2' + assert ordered_uss[4].subject == 'us3' + + +def test_delete_swimlane(client): + project = f.create_project() + membership = f.create_membership(project=project, is_admin=True) + us1 = f.create_userstory(subject='us1', + owner=membership.user, + project=project) + us2 = f.create_userstory(subject='us2', + owner=membership.user, + project=project) + + url = reverse('swimlanes-list') + data = { + "project": project.id, + "name": "S1" + } + client.login(membership.user) + response = client.json.post(url, json.dumps(data)) + + project.refresh_from_db() + swimlane1 = project.swimlanes.filter(name='S1').first() + + # us without swimlane + us3 = f.create_userstory(subject='us3', + owner=membership.user, + project=project) + + data = { + "project": project.id, + "name": "S2" + } + response = client.json.post(url, json.dumps(data)) + + project.refresh_from_db() + swimlane2 = project.swimlanes.filter(name='S2').first() + + # set S2 as default swimlane + project.default_swimlane = swimlane2 + project.save() + + us4 = f.create_userstory(subject='us4', + owner=membership.user, + project=project, + swimlane=swimlane2) + us5 = f.create_userstory(subject='us5', + owner=membership.user, + project=project, + swimlane=swimlane2) + + url = reverse('swimlanes-detail', kwargs={"pk": swimlane1.id}) + + # force an error with swimlane=None + response = client.json.delete(url) + assert response.status_code == 400 + assert response.data['_error_message'] == "Cannot set swimlane to None if there are available swimlanes" + + # force an error with swimlane=non_existing + url = reverse('swimlanes-detail', + kwargs={"pk": swimlane1.id}) + "?moveTo=300" + response = client.json.delete(url) + assert response.status_code == 404 + assert response.data['_error_message'] == "No Swimlane matches the given query." + + # In this moment, they are arranged like: + # us1, us2, us3, us4, us5 + # After deleting swimlane1, they should be like: + # us3, us4, us5, us1, us2 + url = reverse('swimlanes-detail', + kwargs={"pk": swimlane1.id}) + "?moveTo={}".format(swimlane2.id) + response = client.json.delete(url) + + assert response.status_code == 204 + + project.refresh_from_db() + ordered_uss = project.user_stories.all().order_by('kanban_order') + + assert ordered_uss[0].subject == 'us3' + assert ordered_uss[0].swimlane is None + assert ordered_uss[1].subject == 'us4' + assert ordered_uss[1].swimlane == swimlane2 + assert ordered_uss[2].subject == 'us5' + assert ordered_uss[2].swimlane == swimlane2 + assert ordered_uss[3].subject == 'us1' + assert ordered_uss[3].swimlane == swimlane2 + assert ordered_uss[4].subject == 'us2' + assert ordered_uss[4].swimlane == swimlane2 + + +def test_prevent_delete_swimlane_if_is_the_default_and_there_are_more_than_one(client): + project = f.create_project() + membership = f.create_membership(project=project, is_admin=True) + sw1 = f.create_swimlane(project=project) + sw2 = f.create_swimlane(project=project) + + project.refresh_from_db() + assert project.default_swimlane_id == sw1.id + + client.login(membership.user) + + # force an error trying to delete sw1 (the default swimlane) + url = reverse('swimlanes-detail', + kwargs={"pk": sw1.id}) + "?moveTo={}".format(sw2.id) + response = client.json.delete(url) + assert response.status_code == 400 + assert '_error_message' in response.data + + # there is no problem removing not default swimlanes + url = reverse('swimlanes-detail', + kwargs={"pk": sw2.id}) + "?moveTo={}".format(sw1.id) + response = client.json.delete(url) + assert response.status_code == 204 + + # Now sw1 is the only one, so you can delete it + url = reverse('swimlanes-detail', kwargs={"pk": sw1.id}) + response = client.json.delete(url) + assert response.status_code == 204 + + project.refresh_from_db() + assert project.default_swimlane_id is None + + +def test_swimlane_userstory_statuses_creation_and_set_default_wip_limits_for_first_swimlane_creation_only(client): + project = f.create_project() + project.default_us_status.wip_limit = 42 + project.default_us_status.save() + + swimlane1 = f.create_swimlane(project=project) + + assert swimlane1.statuses.count() == 1 + assert swimlane1.statuses.first().wip_limit == 42 + + swimlane2 = f.create_swimlane(project=project) + + assert swimlane2.statuses.count() == 1 + assert swimlane2.statuses.first().wip_limit is None + + +def test_swimlane_userstory_statuses_creation_when_a_new_user_story_status_is_created(client): + project = f.create_project() + + project.default_us_status.wip_limits = 42 + project.default_us_status.save() + + swimlane1 = f.create_swimlane(project=project) + + f.UserStoryStatusFactory(project=project, wip_limit=24) + + assert swimlane1.statuses.count() == 2 + assert swimlane1.statuses.all()[1].wip_limit == 24 + + swimlane2 = f.create_swimlane(project=project) + + f.UserStoryStatusFactory(project=project, wip_limit=32) + + assert swimlane2.statuses.count() == 3 + assert swimlane2.statuses.all()[2].wip_limit is None diff --git a/tests/integration/test_references_sequences.py b/tests/integration/test_references_sequences.py new file mode 100644 index 000000000..b7dd9904c --- /dev/null +++ b/tests/integration/test_references_sequences.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.urls import reverse + +from .. import factories + + +@pytest.fixture +def seq(): + from taiga.projects.references import sequences as seq + return seq + + +@pytest.fixture +def refmodels(): + from taiga.projects.references import models + return models + + +@pytest.mark.django_db +def test_sequences(seq): + seqname = "foo" + assert not seq.exists(seqname) + + # Create and check values + seq.create(seqname) + assert seq.exists(seqname) + assert seq.next_value(seqname) == 1 + assert seq.next_value(seqname) == 2 + + # Delete sequence + seq.delete(seqname) + assert not seq.exists(seqname) + + # Create new seq with same name + # after previously deleted it + seq.create(seqname) + assert seq.next_value(seqname) == 1 + + # Alter sequence + seq.alter(seqname, 4) + assert seq.next_value(seqname) == 5 + + # Delete after alter + seq.delete(seqname) + assert not seq.exists(seqname) + + +@pytest.mark.django_db +def test_unique_reference_per_project(seq, refmodels): + refmodels.Reference.objects.all().delete() + + project = factories.ProjectFactory.create() + seqname = refmodels.make_sequence_name(project) + + assert seqname == "references_project{0}".format(project.id) + assert seq.exists(seqname) + + assert refmodels.make_unique_reference_id(project, create=True) == 1 + assert refmodels.make_unique_reference_id(project, create=True) == 2 + + project.delete() + assert not seq.exists(seqname) + + +@pytest.mark.django_db +def test_regenerate_us_reference_on_project_change(seq, refmodels): + refmodels.Reference.objects.all().delete() + + project1 = factories.ProjectFactory.create() + seqname1 = refmodels.make_sequence_name(project1) + project2 = factories.ProjectFactory.create() + seqname2 = refmodels.make_sequence_name(project2) + + seq.alter(seqname1, 100) + seq.alter(seqname2, 200) + + user_story = factories.UserStoryFactory.create(project=project1) + assert user_story.ref == 101 + + user_story.subject = "other" + user_story.save() + assert user_story.ref == 101 + + user_story.project = project2 + user_story.save() + + assert user_story.ref == 201 + +@pytest.mark.django_db +def test_regenerate_task_reference_on_project_change(seq, refmodels): + refmodels.Reference.objects.all().delete() + + project1 = factories.ProjectFactory.create() + seqname1 = refmodels.make_sequence_name(project1) + project2 = factories.ProjectFactory.create() + seqname2 = refmodels.make_sequence_name(project2) + + seq.alter(seqname1, 100) + seq.alter(seqname2, 200) + + task = factories.TaskFactory.create(project=project1) + assert task.ref == 101 + + task.subject = "other" + task.save() + assert task.ref == 101 + + task.project = project2 + task.save() + + assert task.ref == 201 + +@pytest.mark.django_db +def test_regenerate_issue_reference_on_project_change(seq, refmodels): + refmodels.Reference.objects.all().delete() + + project1 = factories.ProjectFactory.create() + seqname1 = refmodels.make_sequence_name(project1) + project2 = factories.ProjectFactory.create() + seqname2 = refmodels.make_sequence_name(project2) + + seq.alter(seqname1, 100) + seq.alter(seqname2, 200) + + issue = factories.IssueFactory.create(project=project1) + assert issue.ref == 101 + + issue.subject = "other" + issue.save() + assert issue.ref == 101 + + issue.project = project2 + issue.save() + + assert issue.ref == 201 + + +@pytest.mark.django_db +def test_params_validation_in_api_request(client, refmodels): + refmodels.Reference.objects.all().delete() + + user = factories.UserFactory.create() + project = factories.ProjectFactory.create(owner=user) + seqname1 = refmodels.make_sequence_name(project) + role = factories.RoleFactory.create(project=project) + factories.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + + milestone = factories.MilestoneFactory.create(project=project) + us = factories.UserStoryFactory.create(project=project) + task = factories.TaskFactory.create(project=project) + issue = factories.IssueFactory.create(project=project) + wiki_page = factories.WikiPageFactory.create(project=project) + + client.login(user) + + url = reverse("resolver-list") + response = client.json.get(url) + assert response.status_code == 400 + response = client.json.get("{}?project={}".format(url, project.slug)) + assert response.status_code == 200 + response = client.json.get("{}?project={}&ref={}".format(url, project.slug, us.ref)) + assert response.status_code == 200 + response = client.json.get("{}?project={}&ref={}&us={}".format(url, project.slug, us.ref, us.ref)) + assert response.status_code == 400 + response = client.json.get("{}?project={}&ref={}&task={}".format(url, project.slug, us.ref, task.ref)) + assert response.status_code == 400 + response = client.json.get("{}?project={}&ref={}&issue={}".format(url, project.slug, us.ref, issue.ref)) + assert response.status_code == 400 + response = client.json.get("{}?project={}&us={}&task={}".format(url, project.slug, us.ref, task.ref)) + assert response.status_code == 200 + response = client.json.get("{}?project={}&ref={}&milestone={}".format(url, project.slug, us.ref, + milestone.slug)) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_by_ref_calls_in_api_request(client, refmodels): + refmodels.Reference.objects.all().delete() + + user = factories.UserFactory.create() + project = factories.ProjectFactory.create(owner=user) + seqname1 = refmodels.make_sequence_name(project) + role = factories.RoleFactory.create(project=project) + factories.MembershipFactory.create(project=project, user=user, role=role, is_admin=True) + + epic = factories.EpicFactory.create(project=project) + milestone = factories.MilestoneFactory.create(project=project) + us = factories.UserStoryFactory.create(project=project) + task = factories.TaskFactory.create(project=project) + issue = factories.IssueFactory.create(project=project) + wiki_page = factories.WikiPageFactory.create(project=project) + + client.login(user) + + url = reverse("resolver-list") + response = client.json.get("{}?project={}&ref={}".format(url, project.slug, epic.ref)) + assert response.status_code == 200 + assert response.data["epic"] == epic.id + + response = client.json.get("{}?project={}&ref={}".format(url, project.slug, us.ref)) + assert response.status_code == 200 + assert response.data["us"] == us.id + + response = client.json.get("{}?project={}&ref={}".format(url, project.slug, task.ref)) + assert response.status_code == 200 + assert response.data["task"] == task.id + + response = client.json.get("{}?project={}&ref={}".format(url, project.slug, issue.ref)) + assert response.status_code == 200 + assert response.data["issue"] == issue.id + + response = client.json.get("{}?project={}&ref={}".format(url, project.slug, wiki_page.slug)) + assert response.status_code == 200 + assert response.data["wikipage"] == wiki_page.id diff --git a/tests/integration/test_roles.py b/tests/integration/test_roles.py new file mode 100644 index 000000000..445391242 --- /dev/null +++ b/tests/integration/test_roles.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.urls import reverse + +from taiga.users.models import Role +from taiga.projects.models import Membership +from taiga.projects.models import Project + +from .. import factories as f + + +pytestmark = pytest.mark.django_db + + +def test_destroy_role_and_reassign_members(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user1) + role1 = f.RoleFactory.create(project=project) + role2 = f.RoleFactory.create(project=project) + f.MembershipFactory.create(project=project, user=user1, role=role1, is_admin=True) + f.MembershipFactory.create(project=project, user=user2, role=role2) + + url = reverse("roles-detail", args=[role2.pk]) + "?moveTo={}".format(role1.pk) + + client.login(user1) + + response = client.delete(url) + assert response.status_code == 204 + + qs = Role.objects.filter(project=project) + assert qs.count() == 1 + + qs = Membership.objects.filter(project=project, role_id=role2.pk) + assert qs.count() == 0 + + qs = Membership.objects.filter(project=project, role_id=role1.pk) + assert qs.count() == 2 diff --git a/tests/integration/test_searches.py b/tests/integration/test_searches.py new file mode 100644 index 000000000..4ddf2ab5a --- /dev/null +++ b/tests/integration/test_searches.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.urls import reverse + +from .. import factories as f + +from taiga.permissions.choices import MEMBERS_PERMISSIONS +from tests.utils import disconnect_signals, reconnect_signals + + +pytestmark = pytest.mark.django_db(transaction=True) + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def searches_initial_data(): + m = type("InitialData", (object,), {})() + + m.project1 = f.ProjectFactory.create() + m.project2 = f.ProjectFactory.create() + + m.member1 = f.MembershipFactory(project=m.project1, + role__project=m.project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.member2 = f.MembershipFactory(project=m.project1, + role__project=m.project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + + m.epic11 = f.EpicFactory(project=m.project1, subject="Back to the future") + m.epic12 = f.EpicFactory(project=m.project1, tags=["Back", "future"]) + m.epic13 = f.EpicFactory(project=m.project1) + m.epic14 = f.EpicFactory(project=m.project1, description="Backend to the future") + m.epic21 = f.EpicFactory(project=m.project2, subject="Back to the future") + + m.us11 = f.UserStoryFactory(project=m.project1, subject="Back to the future") + m.us12 = f.UserStoryFactory(project=m.project1, description="Back to the future") + m.us13 = f.UserStoryFactory(project=m.project1, tags=["Backend", "future"]) + m.us14 = f.UserStoryFactory(project=m.project1) + m.us21 = f.UserStoryFactory(project=m.project2, subject="Backend to the future") + + m.task11 = f.TaskFactory(project=m.project1, subject="Back to the future") + m.task12 = f.TaskFactory(project=m.project1, tags=["Back", "future"]) + m.task13 = f.TaskFactory(project=m.project1) + m.task14 = f.TaskFactory(project=m.project1, description="Backend to the future") + m.task21 = f.TaskFactory(project=m.project2, subject="Back to the future") + + m.issue11 = f.IssueFactory(project=m.project1, description="Back to the future") + m.issue12 = f.IssueFactory(project=m.project1, tags=["back", "future"]) + m.issue13 = f.IssueFactory(project=m.project1) + m.issue14 = f.IssueFactory(project=m.project1, subject="Backend to the future") + m.issue21 = f.IssueFactory(project=m.project2, subject="Back to the future") + + m.wikipage11 = f.WikiPageFactory(project=m.project1) + m.wikipage12 = f.WikiPageFactory(project=m.project1) + m.wikipage13 = f.WikiPageFactory(project=m.project1, content="Backend to the black") + m.wikipage14 = f.WikiPageFactory(project=m.project1, slug="Back to the black") + m.wikipage21 = f.WikiPageFactory(project=m.project2, slug="Backend to the orange") + + return m + + +def test_search_all_objects_in_my_project(client, searches_initial_data): + data = searches_initial_data + + client.login(data.member1.user) + + response = client.get(reverse("search-list"), {"project": data.project1.id}) + assert response.status_code == 200 + assert response.data["count"] == 20 + assert len(response.data["epics"]) == 4 + assert len(response.data["userstories"]) == 4 + assert len(response.data["tasks"]) == 4 + assert len(response.data["issues"]) == 4 + assert len(response.data["wikipages"]) == 4 + + +def test_search_all_objects_in_project_is_not_mine(client, searches_initial_data): + data = searches_initial_data + + client.login(data.member1.user) + + response = client.get(reverse("search-list"), {"project": data.project2.id}) + assert response.status_code == 200 + assert response.data["count"] == 0 + + +def test_search_text_query_in_my_project(client, searches_initial_data): + data = searches_initial_data + + client.login(data.member1.user) + + response = client.get(reverse("search-list"), {"project": data.project1.id, "text": "future"}) + assert response.status_code == 200 + assert response.data["count"] == 12 + assert len(response.data["epics"]) == 3 + assert set([obj["id"] for obj in response.data["epics"]]) == set([searches_initial_data.epic11.id, searches_initial_data.epic12.id, searches_initial_data.epic14.id]) + assert len(response.data["userstories"]) == 3 + assert set([obj["id"] for obj in response.data["userstories"]]) == set([searches_initial_data.us11.id, searches_initial_data.us13.id, searches_initial_data.us12.id]) + assert len(response.data["tasks"]) == 3 + assert set([obj["id"] for obj in response.data["tasks"]]) == set([searches_initial_data.task11.id, searches_initial_data.task12.id, searches_initial_data.task14.id]) + assert len(response.data["issues"]) == 3 + assert set([obj["id"] for obj in response.data["issues"]]) == set([searches_initial_data.issue14.id, searches_initial_data.issue12.id, searches_initial_data.issue11.id]) + assert len(response.data["wikipages"]) == 0 + + response = client.get(reverse("search-list"), {"project": data.project1.id, "text": "back"}) + assert response.status_code == 200 + assert response.data["count"] == 14 + assert len(response.data["epics"]) == 3 + assert set([obj["id"] for obj in response.data["epics"]]) == set([searches_initial_data.epic11.id, searches_initial_data.epic12.id, searches_initial_data.epic14.id]) + assert len(response.data["userstories"]) == 3 + assert set([obj["id"] for obj in response.data["userstories"]]) == set([searches_initial_data.us11.id, searches_initial_data.us13.id, searches_initial_data.us12.id]) + assert len(response.data["tasks"]) == 3 + assert set([obj["id"] for obj in response.data["tasks"]]) == set([searches_initial_data.task11.id, searches_initial_data.task12.id, searches_initial_data.task14.id]) + assert len(response.data["issues"]) == 3 + assert set([obj["id"] for obj in response.data["issues"]]) == set([searches_initial_data.issue14.id, searches_initial_data.issue12.id, searches_initial_data.issue11.id]) + # Back is a backend substring + assert len(response.data["wikipages"]) == 2 + assert set([obj["id"] for obj in response.data["wikipages"]]) == set([searches_initial_data.wikipage14.id, searches_initial_data.wikipage13.id]) + + +def test_search_text_query_with_an_invalid_project_id(client, searches_initial_data): + data = searches_initial_data + + client.login(data.member1.user) + + response = client.get(reverse("search-list"), {"project": "new", "text": "future"}) + assert response.status_code == 404 diff --git a/tests/integration/test_sitemaps.py b/tests/integration/test_sitemaps.py new file mode 100644 index 000000000..65febf117 --- /dev/null +++ b/tests/integration/test_sitemaps.py @@ -0,0 +1,195 @@ +import pytest + +from django.urls import reverse +from lxml import etree + +from taiga.users.models import User + +from tests import factories as f +from tests.utils import disconnect_signals, reconnect_signals + + +pytestmark = pytest.mark.django_db + + +NAMESPACES = { + "sitemapindex": "http://www.sitemaps.org/schemas/sitemap/0.9", +} + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("InitialData", (object,), {})() + + m.project1 = f.ProjectFactory.create(is_private=False, + is_epics_activated=True, + is_backlog_activated=True, + is_kanban_activated=True, + is_issues_activated=True, + is_wiki_activated=True) + m.project2 = f.ProjectFactory.create(is_private=True, + is_epics_activated=True, + is_backlog_activated=True, + is_kanban_activated=True, + is_issues_activated=True, + is_wiki_activated=True) + + m.epic11 = f.EpicFactory(project=m.project1) + m.epic21 = f.EpicFactory(project=m.project2) + + m.milestone11 = f.MilestoneFactory(project=m.project1) + m.milestone21 = f.MilestoneFactory(project=m.project2) + + m.us11 = f.UserStoryFactory(project=m.project1) + m.us21 = f.UserStoryFactory(project=m.project2) + + m.task11 = f.TaskFactory(project=m.project1) + m.task21 = f.TaskFactory(project=m.project2) + + m.issue11 = f.IssueFactory(project=m.project1) + m.issue21 = f.IssueFactory(project=m.project2) + + m.wikipage11 = f.WikiPageFactory(project=m.project1) + m.wikipage21 = f.WikiPageFactory(project=m.project2) + + return m + + +def test_sitemaps_index(client): + url = reverse('front-sitemap-index') + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 11 # ["/generics", "/projects", "/project_backlogs", "/project_kanbans", "/epics", + # "/milestones", "/userstories", "/tasks", "/issues", "/wikipages", "/users"] + + +def test_sitemap_generics(client, data): + url = reverse('front-sitemap', kwargs={"section": "generics"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 5 # ["/", "/discover", "/login", "/register", "/forgot-password"] + + +def test_sitemap_projects(client, data): + url = reverse('front-sitemap', kwargs={"section": "projects"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 1 + + +def test_sitemap_project_backlogs(client, data): + url = reverse('front-sitemap', kwargs={"section": "project-backlogs"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 1 + + +def test_sitemap_project_kanbans(client, data): + url = reverse('front-sitemap', kwargs={"section": "project-kanbans"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 1 + + +def test_sitemap_epics(client, data): + url = reverse('front-sitemap', kwargs={"section": "epics"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 1 + + +def test_sitemap_milestones(client, data): + url = reverse('front-sitemap', kwargs={"section": "milestones"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 1 + + +def test_sitemap_userstories(client, data): + url = reverse('front-sitemap', kwargs={"section": "userstories"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 1 + + +def test_sitemap_tasks(client, data): + url = reverse('front-sitemap', kwargs={"section": "tasks"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 1 + + +def test_sitemap_issues(client, data): + url = reverse('front-sitemap', kwargs={"section": "issues"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 1 + + +def test_sitemap_wikipages(client, data): + url = reverse('front-sitemap', kwargs={"section": "wikipages"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == 1 + + +def test_sitemap_users(client, data): + url = reverse('front-sitemap', kwargs={"section": "users"}) + + response = client.get(url) + assert response.status_code == 200, response.data + + tree = etree.fromstring(response.content) + urls = tree.xpath("//sitemapindex:loc/text()", namespaces=NAMESPACES) + assert len(urls) == User.objects.filter(is_active=True, is_system=False).count() diff --git a/tests/integration/test_stats.py b/tests/integration/test_stats.py new file mode 100644 index 000000000..0e94ab8a7 --- /dev/null +++ b/tests/integration/test_stats.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from .. import factories as f +from tests.utils import disconnect_signals, reconnect_signals + +from taiga.projects.services.stats import get_stats_for_project + + +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.user = f.UserFactory.create() + + m.project = f.ProjectFactory(is_private=False, owner=m.user) + + m.role1 = f.RoleFactory(project=m.project) + m.role2 = f.RoleFactory(project=m.project) + + m.null_points = f.PointsFactory(project=m.project, value=None) + m.default_points = f.PointsFactory(project=m.project, value=0) + m.points1 = f.PointsFactory(project=m.project, value=1) + m.points2 = f.PointsFactory(project=m.project, value=2) + m.points3 = f.PointsFactory(project=m.project, value=4) + m.points4 = f.PointsFactory(project=m.project, value=8) + m.points5 = f.PointsFactory(project=m.project, value=16) + m.points6 = f.PointsFactory(project=m.project, value=32) + + m.open_status = f.UserStoryStatusFactory(is_closed=False) + m.closed_status = f.UserStoryStatusFactory(is_closed=True) + m.project.default_points = m.default_points + m.project.save() + + m.user_story1 = f.UserStoryFactory(project=m.project, + status=m.open_status, + milestone=None) + m.user_story1.role_points.filter(role=m.role1).update(points=m.points1) + + m.user_story2 = f.UserStoryFactory(project=m.project, + status=m.open_status, + milestone=None) + m.user_story2.role_points.filter(role=m.role1).update(points=m.points2) + + m.user_story3 = f.UserStoryFactory(project=m.project, + status=m.open_status, + milestone=None) + m.user_story3.role_points.filter(role=m.role1).update(points=m.points3) + + m.user_story4 = f.UserStoryFactory(project=m.project, + status=m.open_status, + milestone=None) + m.user_story4.role_points.filter(role=m.role1).update(points=m.points4) + + # 5 and 6 are inclosed milestones + m.user_story5 = f.UserStoryFactory(project=m.project, + status=m.open_status, + milestone__closed=True, + milestone__project=m.project) + + m.user_story5.role_points.filter(role=m.role1).update(points=m.points5) + + m.user_story6 = f.UserStoryFactory(project=m.project, + status=m.open_status, + milestone__closed=True, + milestone__project=m.project) + + m.user_story6.role_points.filter(role=m.role1).update(points=m.points6) + + return m + + +def test_project_defined_points(client, data): + project_stats = get_stats_for_project(data.project) + assert project_stats["defined_points_per_role"] == {data.role1.pk: 63, data.role2.pk: 0} + data.user_story1.role_points.filter(role=data.role1).update(points=data.default_points) + data.user_story1.role_points.filter(role=data.role2).update(points=data.points1) + project_stats = get_stats_for_project(data.project) + assert project_stats["defined_points_per_role"] == {data.role1.pk: 62, data.role2.pk: 1} + + +def test_project_closed_points(client, data): + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {} + data.user_story1.is_closed = True + data.user_story1.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 1, data.role2.pk: 0} + data.user_story2.is_closed = True + data.user_story2.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 3, data.role2.pk: 0} + data.user_story3.is_closed = True + data.user_story3.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 7, data.role2.pk: 0} + data.user_story4.is_closed = True + data.user_story4.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 15, data.role2.pk: 0} + data.user_story5.is_closed = True + data.user_story5.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 31, data.role2.pk: 0} + data.user_story6.is_closed = True + data.user_story6.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points_per_role"] == {data.role1.pk: 63, data.role2.pk: 0} + + project_stats = get_stats_for_project(data.project) + assert project_stats["closed_points"] == 63 + assert project_stats["speed"] == 24 + + +def test_project_assigned_points(client, data): + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 48, data.role2.pk: 0} + data.user_story1.milestone = data.user_story6.milestone + data.user_story1.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 49, data.role2.pk: 0} + data.user_story2.milestone = data.user_story6.milestone + data.user_story2.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 51, data.role2.pk: 0} + data.user_story3.milestone = data.user_story6.milestone + data.user_story3.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 55, data.role2.pk: 0} + data.user_story4.milestone = data.user_story6.milestone + data.user_story4.save() + project_stats = get_stats_for_project(data.project) + assert project_stats["assigned_points_per_role"] == {data.role1.pk: 63, data.role2.pk: 0} diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py new file mode 100644 index 000000000..1f3164b69 --- /dev/null +++ b/tests/integration/test_tasks.py @@ -0,0 +1,1146 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid +import csv + +from datetime import timedelta +from urllib.parse import quote + +from unittest import mock + +from django.urls import reverse +from django.utils import timezone + +from taiga.base.utils import json +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.occ import OCCResourceMixin +from taiga.projects.tasks import services +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.votes.services import add_vote + +from .. import factories as f + +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 +Task #2 +""" + tasks = services.get_tasks_from_bulk(data) + + assert len(tasks) == 2 + assert tasks[0].subject == "Task #1" + assert tasks[1].subject == "Task #2" + + +def test_create_tasks_in_bulk(db): + data = """ +Task #1 +Task #2 +""" + with mock.patch("taiga.projects.tasks.services.db") as db: + tasks = services.create_tasks_in_bulk(data) + db.save_in_bulk.assert_called_once_with(tasks, None, None) + + +def test_create_task_without_status(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + status = f.TaskStatusFactory.create(project=project) + project.default_task_status = status + project.save() + + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("tasks-list") + + data = {"subject": "Test user story", "project": project.id} + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data['status'] == project.default_task_status.id + + +def test_create_task_without_default_values(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) + url = reverse("tasks-list") + + data = {"subject": "Test user story", "project": project.id} + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data['status'] == None + + +def test_api_create_in_bulk_with_status_milestone_userstory(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) + + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + milestone = f.MilestoneFactory(project=project) + us = f.create_userstory(project=project, milestone=milestone) + + url = reverse("tasks-bulk-create") + data = { + "bulk_tasks": "Story #1\nStory #2", + "us_id": us.id, + "project_id": us.project.id, + "milestone_id": us.milestone.id, + "status_id": us.project.default_task_status.id + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert response.data[0]["status"] == us.project.default_task_status.id + + +def test_api_create_in_bulk_with_status_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) + + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + milestone = f.MilestoneFactory(project=project) + us = f.create_userstory(project=project, milestone=milestone) + + url = reverse("tasks-bulk-create") + data = { + "bulk_tasks": "Story #1\nStory #2", + "project_id": us.project.id, + "milestone_id": us.milestone.id, + "status_id": us.project.default_task_status.id + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert response.data[0]["status"] == us.project.default_task_status.id + + +def test_api_create_in_bulk_with_invalid_status(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) + + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + milestone = f.MilestoneFactory(project=project) + us = f.create_userstory(project=project, milestone=milestone) + + status = f.TaskStatusFactory.create() + + + url = reverse("tasks-bulk-create") + data = { + "bulk_tasks": "Story #1\nStory #2", + "us_id": us.id, + "project_id": project.id, + "milestone_id": milestone.id, + "status_id": status.id + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "status_id" in response.data + + +def test_api_create_in_bulk_with_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) + + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + milestone = f.MilestoneFactory() + us = f.create_userstory(project=project) + + url = reverse("tasks-bulk-create") + data = { + "bulk_tasks": "Story #1\nStory #2", + "us_id": us.id, + "project_id": project.id, + "milestone_id": milestone.id, + "status_id": project.default_task_status.id + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "milestone_id" in response.data + + +def test_api_create_in_bulk_with_invalid_userstory_1(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) + + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + milestone = f.MilestoneFactory(project=project) + us = f.create_userstory() + + url = reverse("tasks-bulk-create") + data = { + "bulk_tasks": "Story #1\nStory #2", + "us_id": us.id, + "project_id": project.id, + "milestone_id": milestone.id, + "status_id": project.default_task_status.id + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "us_id" in response.data + + +def test_api_create_in_bulk_with_invalid_userstory_2(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) + + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() + milestone = f.MilestoneFactory(project=project) + us = f.create_userstory(project=project) + + url = reverse("tasks-bulk-create") + data = { + "bulk_tasks": "Story #1\nStory #2", + "us_id": us.id, + "project_id": us.project.id, + "milestone_id": milestone.id, + "status_id": us.project.default_task_status.id + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "us_id" in response.data + + +def test_api_create_invalid_task(client): + # Associated to a milestone and a user story. + # But the User Story is not associated with the milestone + us_milestone = f.MilestoneFactory.create() + us = f.create_userstory(milestone=us_milestone) + f.MembershipFactory.create(project=us.project, user=us.owner, is_admin=True) + us.project.default_task_status = f.TaskStatusFactory.create(project=us.project) + task_milestone = f.MilestoneFactory.create(project=us.project, owner=us.owner) + + url = reverse("tasks-list") + data = { + "user_story": us.id, + "milestone": task_milestone.id, + "subject": "Testing subject", + "status": us.project.default_task_status.id, + "project": us.project.id + } + + client.login(us.owner) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_api_update_order_in_bulk(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "bulk_tasks": [{"task_id": task1.id, "order": 1}, + {"task_id": task2.id, "order": 2}] + } + + client.login(project.owner) + + response1 = client.json.post(url1, json.dumps(data)) + response2 = client.json.post(url2, json.dumps(data)) + + assert response1.status_code == 200, response1.data + assert response2.status_code == 200, response2.data + + +def test_api_update_order_in_bulk_invalid_tasks(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task() + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.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(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_tasks_for_status(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + task1 = f.create_task(project=project) + task2 = f.create_task(project=project, status=task1.status) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "status_id": task1.status.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(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_tasks_for_milestone(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + mil1 = f.MilestoneFactory.create(project=project) + task1 = f.create_task(project=project, milestone=mil1) + task2 = f.create_task(project=project, milestone=mil1) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "milestone_id": mil1.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(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_tasks_for_user_story(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory(project=project) + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "us_id": us1.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(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_status(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.TaskStatusFactory.create() + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "status_id": status.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(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "status_id" in response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "status_id" in response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_milestone(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + mil1 = f.MilestoneFactory.create() + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "milestone_id": mil1.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(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "milestone_id" in response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "milestone_id" in response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_user_story_1(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory() + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "us_id": us1.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(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "us_id" in response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "us_id" in response.data + assert "bulk_tasks" in response.data + + +def test_api_update_order_in_bulk_invalid_user_story_2(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + milestone = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project) + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + task3 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "us_id": us1.id, + "milestone_id": milestone.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(url1, json.dumps(data)) + assert response.status_code == 400, response.data + assert "us_id" in response.data + assert "bulk_tasks" in response.data + + response = client.json.post(url2, json.dumps(data)) + assert response.status_code == 400, response.data + assert "us_id" in response.data + 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") + project = f.ProjectFactory.create(tasks_csv_uuid=uuid.uuid4().hex) + client.login(project.owner) + + response = client.get(url) + assert response.status_code == 404 + + response = client.get("{}?uuid={}".format(url, "not-valid-uuid")) + assert response.status_code == 404 + + +def test_get_valid_csv(client): + url = reverse("tasks-csv") + project = f.ProjectFactory.create(tasks_csv_uuid=uuid.uuid4().hex) + + response = client.get("{}?uuid={}".format(url, project.tasks_csv_uuid)) + assert response.status_code == 200 + + +def test_custom_fields_csv_generation(): + project = f.ProjectFactory.create(tasks_csv_uuid=uuid.uuid4().hex) + attr = f.TaskCustomAttributeFactory.create(project=project, name="attr1", description="desc") + task = f.TaskFactory.create(project=project) + attr_values = task.custom_attributes_values + attr_values.attributes_values = {str(attr.id):"val1"} + attr_values.save() + queryset = project.tasks.all() + data = services.tasks_to_csv(project, queryset) + data.seek(0) + reader = csv.reader(data) + row = next(reader) + assert row[28] == attr.name + row = next(reader) + assert row[28] == "val1" + + +def test_get_tasks_including_attachments(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + task = f.TaskFactory.create(project=project) + f.TaskAttachmentFactory(project=project, content_object=task) + url = reverse("tasks-list") + + client.login(project.owner) + + response = client.get(url) + assert response.status_code == 200 + assert response.data[0].get("attachments") == [] + + url = reverse("tasks-list") + "?include_attachments=1" + response = client.get(url) + assert response.status_code == 200 + assert len(response.data[0].get("attachments")) == 1 + + +def test_api_filter_by_created_date(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_task = f.create_task(owner=user, created_date=one_day_ago) + task = f.create_task(owner=user, subject="test") + + url = reverse("tasks-list") + "?created_date=%s" % ( + quote(task.created_date.isoformat()) + ) + + client.login(task.owner) + response = client.get(url) + number_of_tasks = len(response.data) + + assert response.status_code == 200 + assert number_of_tasks == 1 + assert response.data[0]["subject"] == task.subject + + +def test_api_filter_by_created_date__lt(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_task = f.create_task(owner=user, created_date=one_day_ago) + task = f.create_task(owner=user, subject="test") + + url = reverse("tasks-list") + "?created_date__lt=%s" % ( + quote(task.created_date.isoformat()) + ) + + client.login(task.owner) + response = client.get(url) + number_of_tasks = len(response.data) + + assert response.status_code == 200 + assert response.data[0]["subject"] == old_task.subject + + +def test_api_filter_by_created_date__lte(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_task = f.create_task(owner=user, created_date=one_day_ago) + task = f.create_task(owner=user) + + url = reverse("tasks-list") + "?created_date__lte=%s" % ( + quote(task.created_date.isoformat()) + ) + + client.login(task.owner) + response = client.get(url) + number_of_tasks = len(response.data) + + assert response.status_code == 200 + assert number_of_tasks == 2 + + +def test_api_filter_by_modified_date__gte(client): + user = f.UserFactory(is_superuser=True) + _day_ago = timezone.now() - timedelta(days=1) + + older_task = f.create_task(owner=user) + task = f.create_task(owner=user, subject="test") + # we have to refresh as it slightly differs + task.refresh_from_db() + + assert older_task.modified_date < task.modified_date + + url = reverse("tasks-list") + "?modified_date__gte=%s" % ( + quote(task.modified_date.isoformat()) + ) + + client.login(task.owner) + response = client.get(url) + number_of_tasks = len(response.data) + + assert response.status_code == 200 + assert number_of_tasks == 1 + assert response.data[0]["subject"] == task.subject + + +def test_api_filter_by_finished_date(client): + user = f.UserFactory(is_superuser=True) + project = f.ProjectFactory.create() + status0 = f.TaskStatusFactory.create(project=project, is_closed=True) + + task = f.create_task(owner=user) + finished_task = f.create_task(owner=user, status=status0, subject="test") + + assert finished_task.finished_date + + url = reverse("tasks-list") + "?finished_date__gte=%s" % ( + quote(finished_task.finished_date.isoformat()) + ) + client.login(task.owner) + response = client.get(url) + number_of_tasks = len(response.data) + + assert response.status_code == 200 + assert number_of_tasks == 1 + assert response.data[0]["subject"] == finished_task.subject + + +@pytest.mark.parametrize("field_name", ["estimated_start", "estimated_finish"]) +def test_api_filter_by_milestone__estimated_start_and_end(client, field_name): + user = f.UserFactory(is_superuser=True) + task = f.create_task(owner=user) + + assert task.milestone + assert hasattr(task.milestone, field_name) + date = getattr(task.milestone, field_name) + before = (date - timedelta(days=1)).isoformat() + after = (date + timedelta(days=1)).isoformat() + + client.login(task.owner) + + full_field_name = "milestone__" + field_name + expections = { + full_field_name + "__gte=" + quote(before): 1, + full_field_name + "__gte=" + quote(after): 0, + full_field_name + "__lte=" + quote(before): 0, + full_field_name + "__lte=" + quote(after): 1 + } + + for param, expection in expections.items(): + url = reverse("tasks-list") + "?" + param + response = client.get(url) + number_of_tasks = len(response.data) + + assert response.status_code == 200 + assert number_of_tasks == expection, param + if number_of_tasks > 0: + 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_tags_or_operator(client): + data = create_tasks_fixtures() + project = data["project"] + client.login(data["users"][0]) + tags = data["tags"] + + url = "{}?project={}&tags={},{}".format(reverse('tasks-list'), project.id, tags[0], tags[2]) + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 5 + + +def test_api_filters_data(client): + 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 + response = client.get(url) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 3 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 1 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 5 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 4 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 4 + + ## Filter ((status0 or status3) + response = client.get(url + "&status={},{}".format(status3.id, status0.id)) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3 + + ## Filter ((tag1 and tag2) and (user1 or user2)) + response = client.get(url + "&tags={},{}&owner={},{}".format(tag1, tag2, user1.id, user2.id)) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 2 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 3 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 1 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 3 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3 + + +def test_api_validator_assigned_to_when_update_tasks(client): + project = f.create_project(anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + project_member_owner = f.MembershipFactory.create(project=project, + user=project.owner, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_member = f.MembershipFactory.create(project=project, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_no_member = f.MembershipFactory.create(is_admin=True) + + task = f.create_task(project=project, milestone__project=project, user_story=None, owner=project.owner) + + url = reverse('tasks-detail', kwargs={"pk": task.pk}) + + # assign + data = { + "assigned_to": project_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == project_member.user.id + + # unassign + data = { + "assigned_to": None, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == None + + # assign to invalid user + data = { + "assigned_to": project_no_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + + +def test_api_validator_assigned_to_when_create_tasks(client): + project = f.create_project(anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + project_member_owner = f.MembershipFactory.create(project=project, + user=project.owner, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_member = f.MembershipFactory.create(project=project, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_no_member = f.MembershipFactory.create(is_admin=True) + + url = reverse('tasks-list') + + # assign + data = { + "subject": "test", + "project": project.id, + "assigned_to": project_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == project_member.user.id + + # unassign + data = { + "subject": "test", + "project": project.id, + "assigned_to": None, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == None + + # assign to invalid user + data = { + "subject": "test", + "project": project.id, + "assigned_to": project_no_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + + +def test_promote_task_to_us(client): + user_1 = f.UserFactory.create() + user_2 = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user_1) + f.MembershipFactory.create(project=project, user=user_1, is_admin=True) + f.MembershipFactory.create(project=project, user=user_2, is_admin=False) + task = f.TaskFactory.create(project=project, owner=user_1, assigned_to=user_2) + task.add_watcher(user_1) + task.add_watcher(user_2) + add_vote(task, user_1) + add_vote(task, user_2) + + f.TaskAttachmentFactory(project=project, content_object=task, owner=user_1) + + f.HistoryEntryFactory.create( + project=project, + user={"pk": user_1.id}, + comment="Test comment", + key="tasks.task:{}".format(task.id), + is_hidden=False, + diff=[], + ) + + f.HistoryEntryFactory.create( + project=project, + user={"pk": user_2.id}, + comment="Test comment 2", + key="tasks.task:{}".format(task.id), + is_hidden=False, + diff=[] + ) + + client.login(user_1) + + url = reverse('tasks-promote-to-user-story', kwargs={"pk": task.pk}) + data = {"project_id": project.id} + promote_response = client.json.post(url, json.dumps(data)) + + us_ref = promote_response.data.pop() + us = UserStory.objects.get(ref=us_ref) + us_response = client.get(reverse("userstories-detail", args=[us.pk]), + {"include_attachments": True}) + + assert promote_response.status_code == 200, promote_response.data + assert us_response.data["subject"] == task.subject + assert us_response.data["description"] == task.description + assert us_response.data["owner"] == task.owner_id + assert us_response.data["generated_from_task"] == None + assert us_response.data["assigned_users"] == {user_2.id} + assert us_response.data["total_watchers"] == 2 + assert us_response.data["total_attachments"] == 1 + assert us_response.data["total_comments"] == 2 + assert us_response.data["due_date"] == task.due_date + assert us_response.data["is_blocked"] == task.is_blocked + assert us_response.data["blocked_note"] == task.blocked_note + assert us_response.data["total_voters"] == 2 + + # check if task is deleted + assert us_response.data["from_task_ref"] == us.from_task_ref + assert not Task.objects.filter(pk=task.id).exists() diff --git a/tests/integration/test_tasks_tags.py b/tests/integration/test_tasks_tags.py new file mode 100644 index 000000000..5996195a5 --- /dev/null +++ b/tests/integration/test_tasks_tags.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from unittest import mock +from collections import OrderedDict + +from django.urls import reverse + +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +def test_api_task_add_new_tags_with_error(client): + project = f.ProjectFactory.create() + task = f.create_task(project=project, status__project=project, milestone=None, user_story=None) + f.MembershipFactory.create(project=project, user=task.owner, is_admin=True) + url = reverse("tasks-detail", kwargs={"pk": task.pk}) + data = { + "tags": [], + "version": task.version + } + + client.login(task.owner) + + data["tags"] = [1] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [["back"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [["back", "#cccc"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [[1, "#ccc"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + +def test_api_task_add_new_tags_without_colors(client): + project = f.ProjectFactory.create() + task = f.create_task(project=project, status__project=project, milestone=None, user_story=None) + f.MembershipFactory.create(project=project, user=task.owner, is_admin=True) + url = reverse("tasks-detail", kwargs={"pk": task.pk}) + data = { + "tags": [ + ["back", None], + ["front", None], + ["ux", None] + ], + "version": task.version + } + + client.login(task.owner) + + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200, response.data + + tags_colors = OrderedDict(project.tags_colors) + assert not tags_colors.keys() + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors + + +def test_api_task_add_new_tags_with_colors(client): + project = f.ProjectFactory.create() + task = f.create_task(project=project, status__project=project, milestone=None, user_story=None) + f.MembershipFactory.create(project=project, user=task.owner, is_admin=True) + url = reverse("tasks-detail", kwargs={"pk": task.pk}) + data = { + "tags": [ + ["back", "#fff8e7"], + ["front", None], + ["ux", "#fabada"] + ], + "version": task.version + } + + client.login(task.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + + tags_colors = OrderedDict(project.tags_colors) + assert not tags_colors.keys() + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors + assert tags_colors["back"] == "#fff8e7" + assert tags_colors["ux"] == "#fabada" + + +def test_api_create_new_task_with_tags(client): + project = f.ProjectFactory.create(tags_colors=[["front", "#aaaaaa"], ["ux", "#fabada"]]) + status = f.TaskStatusFactory.create(project=project) + project.default_task_status = status + project.save() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("tasks-list") + + data = { + "subject": "Test user story", + "project": project.id, + "tags": [ + ["back", "#fff8e7"], + ["front", "#bbbbbb"], + ["ux", None] + ] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + + task_tags_colors = OrderedDict(response.data["tags"]) + + assert task_tags_colors["back"] == "#fff8e7" + assert task_tags_colors["front"] == "#aaaaaa" + assert task_tags_colors["ux"] == "#fabada" + + tags_colors = OrderedDict(project.tags_colors) + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert tags_colors["back"] == "#fff8e7" + assert tags_colors["ux"] == "#fabada" + assert tags_colors["front"] == "#aaaaaa" diff --git a/tests/integration/test_throwttling.py b/tests/integration/test_throwttling.py new file mode 100644 index 000000000..2295e6b73 --- /dev/null +++ b/tests/integration/test_throwttling.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest import mock + +from django.urls import reverse +from django.core.cache import cache + +from taiga.base.utils import json + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +import_rate_path = "taiga.export_import.throttling.ImportModeRateThrottle.get_rate" + + +def test_anonimous_throttling_policy(client, settings): + f.create_project() + url = reverse("projects-list") + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "2/min" + + with mock.patch(import_rate_path) as import_rate: + import_rate.return_value = "7/day" + + cache.clear() + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 429 + + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + cache.clear() + + +def test_user_throttling_policy(client, settings): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "4/min" + + client.login(project.owner) + + with mock.patch(import_rate_path) as import_rate: + import_rate.return_value = "7/day" + + cache.clear() + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 429 + + client.logout() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + cache.clear() + + +def test_import_mode_throttling_policy(client, settings): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.save() + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Test" + } + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "2/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "4/min" + + client.login(project.owner) + + with mock.patch(import_rate_path) as import_rate: + import_rate.return_value = "7/day" + + cache.clear() + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 429 + + client.logout() + + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + cache.clear() diff --git a/tests/integration/test_timeline.py b/tests/integration/test_timeline.py new file mode 100644 index 000000000..7049e86f2 --- /dev/null +++ b/tests/integration/test_timeline.py @@ -0,0 +1,671 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from datetime import timedelta +import pytest + +from .. import factories +from django.contrib.auth.models import AnonymousUser +from django.utils import timezone +from taiga.timeline.service import build_project_namespace, build_user_namespace, get_timeline +from taiga.projects.history import services as history_services +from taiga.timeline import service +from taiga.timeline.models import Timeline +from taiga.timeline.serializers import TimelineSerializer + + +pytestmark = pytest.mark.django_db + + +def test_add_to_object_timeline(): + Timeline.objects.all().delete() + user1 = factories.UserFactory() + task = factories.TaskFactory() + + service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: id(x)) + + service._add_to_object_timeline(user1, task, "test", task.created_date) + + assert Timeline.objects.filter(object_id=user1.id).count() == 2 + assert Timeline.objects.order_by("-id")[0].data == id(task) + + +def test_get_timeline(): + Timeline.objects.all().delete() + + user1 = factories.UserFactory() + user2 = factories.UserFactory() + user3 = factories.UserFactory() + task1= factories.TaskFactory() + task2= factories.TaskFactory() + task3= factories.TaskFactory() + task4= factories.TaskFactory() + + service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: id(x)) + + service._add_to_object_timeline(user1, task1, "test", task1.created_date) + service._add_to_object_timeline(user1, task2, "test", task2.created_date) + service._add_to_object_timeline(user1, task3, "test", task3.created_date) + service._add_to_object_timeline(user1, task4, "test", task4.created_date) + service._add_to_object_timeline(user2, task1, "test", task1.created_date) + + assert Timeline.objects.filter(object_id=user1.id).count() == 5 + assert Timeline.objects.filter(object_id=user2.id).count() == 2 + assert Timeline.objects.filter(object_id=user3.id).count() == 1 + + +def test_filter_timeline_no_privileges(): + Timeline.objects.all().delete() + user1 = factories.UserFactory() + user2 = factories.UserFactory() + task1= factories.TaskFactory() + + service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: id(x)) + service._add_to_object_timeline(user1, task1, "test", task1.created_date) + timeline = Timeline.objects.exclude(event_type="users.user.create") + timeline = service.filter_timeline_for_user(timeline, user2) + assert timeline.count() == 0 + + +def test_filter_timeline_public_project(): + Timeline.objects.all().delete() + user1 = factories.UserFactory() + user2 = factories.UserFactory() + project = factories.ProjectFactory.create(is_private=False) + task1= factories.TaskFactory() + task2= factories.TaskFactory.create(project=project) + + service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: id(x)) + service._add_to_object_timeline(user1, task1, "test", task1.created_date) + service._add_to_object_timeline(user1, task2, "test", task2.created_date) + timeline = Timeline.objects.exclude(event_type="users.user.create") + timeline = service.filter_timeline_for_user(timeline, user2) + assert timeline.count() == 1 + + +def test_filter_timeline_private_project_anon_permissions(): + Timeline.objects.all().delete() + user1 = factories.UserFactory() + user2 = factories.UserFactory() + project = factories.ProjectFactory.create(is_private=True, anon_permissions= ["view_tasks"]) + task1= factories.TaskFactory() + task2= factories.TaskFactory.create(project=project) + + service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: id(x)) + service._add_to_object_timeline(user1, task1, "test", task1.created_date) + service._add_to_object_timeline(user1, task2, "test", task2.created_date) + timeline = Timeline.objects.exclude(event_type="users.user.create") + timeline = service.filter_timeline_for_user(timeline, user2) + assert timeline.count() == 1 + + +def test_filter_timeline_private_project_member_permissions(): + Timeline.objects.all().delete() + user1 = factories.UserFactory() + user2 = factories.UserFactory() + project = factories.ProjectFactory.create(is_private=True) + membership = factories.MembershipFactory.create(user=user2, project=project) + membership.role.permissions = ["view_tasks"] + membership.role.save() + task1= factories.TaskFactory() + task2= factories.TaskFactory.create(project=project) + + service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: id(x)) + service._add_to_object_timeline(user1, task1, "test", task1.created_date) + service._add_to_object_timeline(user1, task2, "test", task2.created_date) + timeline = Timeline.objects.exclude(event_type="users.user.create") + timeline = service.filter_timeline_for_user(timeline, user2) + assert timeline.count() == 3 + + +def test_filter_timeline_private_project_member_admin(): + Timeline.objects.all().delete() + user1 = factories.UserFactory() + user2 = factories.UserFactory() + project = factories.ProjectFactory.create(is_private=True) + membership = factories.MembershipFactory.create(user=user2, project=project, is_admin=True) + task1= factories.TaskFactory() + task2= factories.TaskFactory.create(project=project) + + service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: id(x)) + service._add_to_object_timeline(user1, task1, "test", task1.created_date) + service._add_to_object_timeline(user1, task2, "test", task2.created_date) + timeline = Timeline.objects.exclude(event_type="users.user.create") + timeline = service.filter_timeline_for_user(timeline, user2) + assert timeline.count() == 3 + + +def test_filter_timeline_private_project_member_superuser(): + Timeline.objects.all().delete() + user1 = factories.UserFactory() + user2 = factories.UserFactory(is_superuser=True) + project = factories.ProjectFactory.create(is_private=True) + + task1= factories.TaskFactory() + task2= factories.TaskFactory.create(project=project) + + service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: id(x)) + service._add_to_object_timeline(user1, task1, "test", task1.created_date) + service._add_to_object_timeline(user1, task2, "test", task2.created_date) + timeline = Timeline.objects.exclude(event_type="users.user.create") + timeline = service.filter_timeline_for_user(timeline, user2) + assert timeline.count() == 2 + + +def test_create_project_timeline(): + project = factories.ProjectFactory.create(name="test project timeline") + history_services.take_snapshot(project, user=project.owner) + project_timeline = service.get_project_timeline(project) + assert project_timeline[0].event_type == "projects.project.create" + assert project_timeline[0].data["project"]["name"] == "test project timeline" + assert project_timeline[0].data["user"]["id"] == project.owner.id + + +def test_create_milestone_timeline(): + milestone = factories.MilestoneFactory.create(name="test milestone timeline") + history_services.take_snapshot(milestone, user=milestone.owner) + milestone_timeline = service.get_project_timeline(milestone.project) + assert milestone_timeline[0].event_type == "milestones.milestone.create" + assert milestone_timeline[0].data["milestone"]["name"] == "test milestone timeline" + assert milestone_timeline[0].data["user"]["id"] == milestone.owner.id + + +def test_create_user_story_timeline(): + user_story = factories.UserStoryFactory.create(subject="test us timeline") + history_services.take_snapshot(user_story, user=user_story.owner) + project_timeline = service.get_project_timeline(user_story.project) + assert project_timeline[0].event_type == "userstories.userstory.create" + assert project_timeline[0].data["userstory"]["subject"] == "test us timeline" + assert project_timeline[0].data["user"]["id"] == user_story.owner.id + + +def test_create_issue_timeline(): + issue = factories.IssueFactory.create(subject="test issue timeline") + history_services.take_snapshot(issue, user=issue.owner) + project_timeline = service.get_project_timeline(issue.project) + assert project_timeline[0].event_type == "issues.issue.create" + assert project_timeline[0].data["issue"]["subject"] == "test issue timeline" + assert project_timeline[0].data["user"]["id"] == issue.owner.id + + +def test_create_task_timeline(): + task = factories.TaskFactory.create(subject="test task timeline") + history_services.take_snapshot(task, user=task.owner) + project_timeline = service.get_project_timeline(task.project) + assert project_timeline[0].event_type == "tasks.task.create" + assert project_timeline[0].data["task"]["subject"] == "test task timeline" + assert project_timeline[0].data["user"]["id"] == task.owner.id + + +def test_create_wiki_page_timeline(): + page = factories.WikiPageFactory.create(slug="test wiki page timeline") + history_services.take_snapshot(page, user=page.owner) + project_timeline = service.get_project_timeline(page.project) + assert project_timeline[0].event_type == "wiki.wikipage.create" + assert project_timeline[0].data["wikipage"]["slug"] == "test wiki page timeline" + assert project_timeline[0].data["user"]["id"] == page.owner.id + + +def test_create_membership_timeline(): + membership = factories.MembershipFactory.create() + project_timeline = service.get_project_timeline(membership.project) + user_timeline = service.get_user_timeline(membership.user) + assert project_timeline[0].event_type == "projects.membership.create" + assert project_timeline[0].data["project"]["id"] == membership.project.id + assert project_timeline[0].data["user"]["id"] == membership.user.id + assert user_timeline[0].event_type == "projects.membership.create" + assert user_timeline[0].data["project"]["id"] == membership.project.id + assert user_timeline[0].data["user"]["id"] == membership.user.id + + +def test_update_project_timeline(): + user_watcher= factories.UserFactory() + project = factories.ProjectFactory.create(name="test project timeline") + history_services.take_snapshot(project, user=project.owner) + project.add_watcher(user_watcher) + project.name = "test project timeline updated" + project.save() + history_services.take_snapshot(project, user=project.owner) + project_timeline = service.get_project_timeline(project) + assert project_timeline[0].event_type == "projects.project.change" + assert project_timeline[0].data["project"]["name"] == "test project timeline updated" + assert project_timeline[0].data["values_diff"]["name"][0] == "test project timeline" + assert project_timeline[0].data["values_diff"]["name"][1] == "test project timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "projects.project.change" + assert user_watcher_timeline[0].data["project"]["name"] == "test project timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["name"][0] == "test project timeline" + assert user_watcher_timeline[0].data["values_diff"]["name"][1] == "test project timeline updated" + + +def test_update_milestone_timeline(): + user_watcher= factories.UserFactory() + milestone = factories.MilestoneFactory.create(name="test milestone timeline") + history_services.take_snapshot(milestone, user=milestone.owner) + milestone.add_watcher(user_watcher) + milestone.name = "test milestone timeline updated" + milestone.save() + history_services.take_snapshot(milestone, user=milestone.owner) + project_timeline = service.get_project_timeline(milestone.project) + assert project_timeline[0].event_type == "milestones.milestone.change" + assert project_timeline[0].data["milestone"]["name"] == "test milestone timeline updated" + assert project_timeline[0].data["values_diff"]["name"][0] == "test milestone timeline" + assert project_timeline[0].data["values_diff"]["name"][1] == "test milestone timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "milestones.milestone.change" + assert user_watcher_timeline[0].data["milestone"]["name"] == "test milestone timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["name"][0] == "test milestone timeline" + assert user_watcher_timeline[0].data["values_diff"]["name"][1] == "test milestone timeline updated" + + +def test_update_user_story_timeline(): + user_watcher= factories.UserFactory() + user_story = factories.UserStoryFactory.create(subject="test us timeline") + history_services.take_snapshot(user_story, user=user_story.owner) + user_story.add_watcher(user_watcher) + user_story.subject = "test us timeline updated" + user_story.save() + history_services.take_snapshot(user_story, user=user_story.owner) + project_timeline = service.get_project_timeline(user_story.project) + assert project_timeline[0].event_type == "userstories.userstory.change" + assert project_timeline[0].data["userstory"]["subject"] == "test us timeline updated" + assert project_timeline[0].data["values_diff"]["subject"][0] == "test us timeline" + assert project_timeline[0].data["values_diff"]["subject"][1] == "test us timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "userstories.userstory.change" + assert user_watcher_timeline[0].data["userstory"]["subject"] == "test us timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["subject"][0] == "test us timeline" + assert user_watcher_timeline[0].data["values_diff"]["subject"][1] == "test us timeline updated" + + +def test_update_issue_timeline(): + user_watcher= factories.UserFactory() + issue = factories.IssueFactory.create(subject="test issue timeline") + history_services.take_snapshot(issue, user=issue.owner) + issue.add_watcher(user_watcher) + issue.subject = "test issue timeline updated" + issue.save() + history_services.take_snapshot(issue, user=issue.owner) + project_timeline = service.get_project_timeline(issue.project) + assert project_timeline[0].event_type == "issues.issue.change" + assert project_timeline[0].data["issue"]["subject"] == "test issue timeline updated" + assert project_timeline[0].data["values_diff"]["subject"][0] == "test issue timeline" + assert project_timeline[0].data["values_diff"]["subject"][1] == "test issue timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "issues.issue.change" + assert user_watcher_timeline[0].data["issue"]["subject"] == "test issue timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["subject"][0] == "test issue timeline" + assert user_watcher_timeline[0].data["values_diff"]["subject"][1] == "test issue timeline updated" + + +def test_update_task_timeline(): + user_watcher= factories.UserFactory() + task = factories.TaskFactory.create(subject="test task timeline") + history_services.take_snapshot(task, user=task.owner) + task.add_watcher(user_watcher) + task.subject = "test task timeline updated" + task.save() + history_services.take_snapshot(task, user=task.owner) + project_timeline = service.get_project_timeline(task.project) + assert project_timeline[0].event_type == "tasks.task.change" + assert project_timeline[0].data["task"]["subject"] == "test task timeline updated" + assert project_timeline[0].data["values_diff"]["subject"][0] == "test task timeline" + assert project_timeline[0].data["values_diff"]["subject"][1] == "test task timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "tasks.task.change" + assert user_watcher_timeline[0].data["task"]["subject"] == "test task timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["subject"][0] == "test task timeline" + assert user_watcher_timeline[0].data["values_diff"]["subject"][1] == "test task timeline updated" + + +def test_update_wiki_page_timeline(): + user_watcher= factories.UserFactory() + page = factories.WikiPageFactory.create(slug="test wiki page timeline") + history_services.take_snapshot(page, user=page.owner) + page.add_watcher(user_watcher) + page.slug = "test wiki page timeline updated" + page.save() + history_services.take_snapshot(page, user=page.owner) + project_timeline = service.get_project_timeline(page.project) + assert project_timeline[0].event_type == "wiki.wikipage.change" + assert project_timeline[0].data["wikipage"]["slug"] == "test wiki page timeline updated" + assert project_timeline[0].data["values_diff"]["slug"][0] == "test wiki page timeline" + assert project_timeline[0].data["values_diff"]["slug"][1] == "test wiki page timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "wiki.wikipage.change" + assert user_watcher_timeline[0].data["wikipage"]["slug"] == "test wiki page timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["slug"][0] == "test wiki page timeline" + assert user_watcher_timeline[0].data["values_diff"]["slug"][1] == "test wiki page timeline updated" + + +def test_delete_project_timeline(): + project = factories.ProjectFactory.create(name="test project timeline") + user_watcher= factories.UserFactory() + project.add_watcher(user_watcher) + history_services.take_snapshot(project, user=project.owner, delete=True) + user_timeline = service.get_project_timeline(project) + assert user_timeline[0].event_type == "projects.project.delete" + assert user_timeline[0].data["project"]["id"] == project.id + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "projects.project.delete" + assert user_watcher_timeline[0].data["project"]["id"] == project.id + + +def test_delete_milestone_timeline(): + milestone = factories.MilestoneFactory.create(name="test milestone timeline") + user_watcher= factories.UserFactory() + milestone.add_watcher(user_watcher) + history_services.take_snapshot(milestone, user=milestone.owner, delete=True) + project_timeline = service.get_project_timeline(milestone.project) + assert project_timeline[0].event_type == "milestones.milestone.delete" + assert project_timeline[0].data["milestone"]["name"] == "test milestone timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "milestones.milestone.delete" + assert user_watcher_timeline[0].data["milestone"]["name"] == "test milestone timeline" + + +def test_delete_user_story_timeline(): + user_story = factories.UserStoryFactory.create(subject="test us timeline") + user_watcher= factories.UserFactory() + user_story.add_watcher(user_watcher) + history_services.take_snapshot(user_story, user=user_story.owner, delete=True) + project_timeline = service.get_project_timeline(user_story.project) + assert project_timeline[0].event_type == "userstories.userstory.delete" + assert project_timeline[0].data["userstory"]["subject"] == "test us timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "userstories.userstory.delete" + assert user_watcher_timeline[0].data["userstory"]["subject"] == "test us timeline" + + +def test_delete_issue_timeline(): + issue = factories.IssueFactory.create(subject="test issue timeline") + user_watcher= factories.UserFactory() + issue.add_watcher(user_watcher) + history_services.take_snapshot(issue, user=issue.owner, delete=True) + project_timeline = service.get_project_timeline(issue.project) + assert project_timeline[0].event_type == "issues.issue.delete" + assert project_timeline[0].data["issue"]["subject"] == "test issue timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "issues.issue.delete" + assert user_watcher_timeline[0].data["issue"]["subject"] == "test issue timeline" + + +def test_delete_task_timeline(): + task = factories.TaskFactory.create(subject="test task timeline") + user_watcher= factories.UserFactory() + task.add_watcher(user_watcher) + history_services.take_snapshot(task, user=task.owner, delete=True) + project_timeline = service.get_project_timeline(task.project) + assert project_timeline[0].event_type == "tasks.task.delete" + assert project_timeline[0].data["task"]["subject"] == "test task timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "tasks.task.delete" + assert user_watcher_timeline[0].data["task"]["subject"] == "test task timeline" + + +def test_delete_wiki_page_timeline(): + page = factories.WikiPageFactory.create(slug="test wiki page timeline") + user_watcher= factories.UserFactory() + page.add_watcher(user_watcher) + history_services.take_snapshot(page, user=page.owner, delete=True) + project_timeline = service.get_project_timeline(page.project) + assert project_timeline[0].event_type == "wiki.wikipage.delete" + assert project_timeline[0].data["wikipage"]["slug"] == "test wiki page timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "wiki.wikipage.delete" + assert user_watcher_timeline[0].data["wikipage"]["slug"] == "test wiki page timeline" + + +def test_delete_membership_timeline(): + membership = factories.MembershipFactory.create() + membership.delete() + project_timeline = service.get_project_timeline(membership.project) + user_timeline = service.get_user_timeline(membership.user) + assert project_timeline[0].event_type == "projects.membership.delete" + assert project_timeline[0].data["project"]["id"] == membership.project.id + assert project_timeline[0].data["user"]["id"] == membership.user.id + assert user_timeline[0].event_type == "projects.membership.delete" + assert user_timeline[0].data["project"]["id"] == membership.project.id + assert user_timeline[0].data["user"]["id"] == membership.user.id + + +def test_comment_user_story_timeline(): + user_story = factories.UserStoryFactory.create(subject="test us timeline") + history_services.take_snapshot(user_story, user=user_story.owner) + history_services.take_snapshot(user_story, user=user_story.owner, + comment="testing comment") + project_timeline = service.get_project_timeline(user_story.project) + assert project_timeline[0].event_type == "userstories.userstory.change" + assert project_timeline[0].data["userstory"]["subject"] \ + == "test us timeline" + assert project_timeline[0].data["comment"] == "testing comment" + + +def test_owner_user_story_timeline(): + user_story = factories.UserStoryFactory.create(subject="test us timeline") + history_services.take_snapshot(user_story, user=user_story.owner) + user_timeline = service.get_user_timeline(user_story.owner) + assert user_timeline[0].event_type == "userstories.userstory.create" + assert user_timeline[0].data["userstory"]["subject"] == "test us timeline" + + +def test_assigned_to_user_story_timeline(): + membership = factories.MembershipFactory.create() + user_story = factories.UserStoryFactory.create(subject="test us timeline", + assigned_to=membership.user, + project=membership.project) + history_services.take_snapshot(user_story, user=user_story.owner) + user_timeline = service.get_profile_timeline(user_story.assigned_to) + assert user_timeline[0].event_type == "userstories.userstory.create" + assert user_timeline[0].data["userstory"]["subject"] == "test us timeline" + + +def test_due_date_user_story_timeline(): + initial_due_date = timezone.now() + timedelta(days=1) + membership = factories.MembershipFactory.create() + user_story = factories.UserStoryFactory.create(subject="test us timeline", + due_date=initial_due_date, + project=membership.project) + history_services.take_snapshot(user_story, user=user_story.owner) + + new_due_date = timezone.now() + timedelta(days=3) + user_story.due_date = new_due_date + user_story.save() + + history_services.take_snapshot(user_story, user=user_story.owner) + user_timeline = service.get_profile_timeline(user_story.owner) + + assert user_timeline[0].event_type == "userstories.userstory.change" + assert user_timeline[0].data["values_diff"]['due_date'] == [str(initial_due_date.date()), + str(new_due_date.date())] + + +def test_assigned_users_user_story_timeline(): + membership = factories.MembershipFactory.create() + user_story = factories.UserStoryFactory.create(subject="test us timeline", + project=membership.project) + history_services.take_snapshot(user_story, user=user_story.owner) + user_timeline = service.get_profile_timeline(user_story.owner) + + assert user_timeline[0].event_type == "userstories.userstory.create" + assert user_timeline[0].data["userstory"]["subject"] == "test us timeline" + + user_story.assigned_to = membership.user + user_story.assigned_users.set([membership.user]) + user_story.save() + + history_services.take_snapshot(user_story, user=user_story.owner) + + user_timeline = service.get_profile_timeline(user_story.owner) + + assert user_timeline[0].event_type == "userstories.userstory.change" + assert "assigned_to" not in user_timeline[0].data["values_diff"].keys() + assert user_timeline[0].data["values_diff"]['assigned_users'] == \ + [None, membership.user.username] + + +def test_user_data_for_non_system_users(): + user_story = factories.UserStoryFactory.create(subject="test us timeline") + history_services.take_snapshot(user_story, user=user_story.owner) + project_timeline = service.get_project_timeline(user_story.project) + serialized_obj = TimelineSerializer(project_timeline[0]) + serialized_obj.data["data"]["user"]["is_profile_visible"] = True + + +def test_user_data_for_system_users(): + user_story = factories.UserStoryFactory.create(subject="test us timeline") + user_story.owner.is_system = True + user_story.owner.save() + history_services.take_snapshot(user_story, user=user_story.owner) + project_timeline = service.get_project_timeline(user_story.project) + serialized_obj = TimelineSerializer(project_timeline[0]) + serialized_obj.data["data"]["user"]["is_profile_visible"] = False + + +def test_user_data_for_unactived_users(): + user_story = factories.UserStoryFactory.create(subject="test us timeline") + user_story.owner.cancel() + user_story.owner.save() + history_services.take_snapshot(user_story, user=user_story.owner) + project_timeline = service.get_project_timeline(user_story.project) + serialized_obj = TimelineSerializer(project_timeline[0]) + serialized_obj.data["data"]["user"]["is_profile_visible"] = False + serialized_obj.data["data"]["user"]["username"] = "deleted-user" + + +def test_timeline_error_use_member_ids_instead_of_memberships_ids(): + user_story = factories.UserStoryFactory.create( + subject="test error use member ids instead of " + "memberships ids") + + member_user = user_story.owner + external_user = factories.UserFactory.create() + + membership = factories.MembershipFactory.create(project=user_story.project, + user=member_user, + id=external_user.id) + + history_services.take_snapshot(user_story, user=member_user) + + user_timeline = service.get_profile_timeline(member_user) + + assert len(user_timeline) == 3 + assert user_timeline[0].event_type == "userstories.userstory.create" + assert user_timeline[1].event_type == "projects.membership.create" + assert user_timeline[2].event_type == "users.user.create" + + external_user_timeline = service.get_profile_timeline(external_user) + assert len(external_user_timeline) == 1 + assert external_user_timeline[0].event_type == "users.user.create" + + +def test_epic_related_uss(): + Timeline.objects.all().delete() + + # Users + public_project_owner = factories.UserFactory.create(username="Public project's owner") + not_qualified_private_project_member = factories.UserFactory.create(username="Unprivileged private role member") + private_project_owner = factories.UserFactory.create(username="Privileged private role member") + + # A public project, containing a public epic which contains a private us from a private project + public_project = factories.ProjectFactory.create(is_private=False, + owner=public_project_owner, + anon_permissions=[], + public_permissions=["view_us"]) + factories.MembershipFactory.create(project=public_project, user=public_project.owner, is_admin=True) + public_epic = factories.EpicFactory.create(project=public_project, owner=public_project_owner) + public_us = factories.UserStoryFactory.create(project=public_project, owner=public_project_owner) + related_public_us = factories.RelatedUserStory.create(epic=public_epic, user_story=public_us) + + # A private project, containing the private user story related to the public epic from the public project + private_project = factories.ProjectFactory.create(is_private=True, + owner=private_project_owner, + anon_permissions=[], + public_permissions=[]) + not_qualified_role = factories.RoleFactory(project=private_project, permissions=[]) + qualified_role = factories.RoleFactory(project=private_project, permissions=["view_us"]) + factories.MembershipFactory.create(project=private_project, + user=not_qualified_private_project_member, + role=not_qualified_role) + factories.MembershipFactory.create(project=private_project, + user=private_project_owner, + role=qualified_role) + private_us = factories.UserStoryFactory.create(project=private_project, owner=private_project_owner) + related_private_us = factories.RelatedUserStory.create(epic=public_epic, user_story=private_us) + + service.register_timeline_implementation("epics.relateduserstory", "test", lambda x, extra_data=None: id(x)) + project_namespace = build_project_namespace(public_project) + # Timeline entries regarding the first epic-related public US, for both a user and a project namespace + service._add_to_object_timeline(public_project, related_public_us, "create", timezone.now(), project_namespace) + service._add_to_object_timeline(public_project_owner, related_public_us, "create", timezone.now(), + build_user_namespace(public_project_owner)) + # Timeline entries regarding the first epic-related private US, for both a user and a project namespace + service._add_to_object_timeline(public_project, related_private_us, "create", timezone.now(), project_namespace) + service._add_to_object_timeline(private_project_owner, related_private_us, "create", timezone.now(), + build_user_namespace(private_project_owner)) + + """ + # A list of users for the test iterations + # + # [index0] An anonymous user, who doesn't even have rights to see neither public nor private related USs. + # [index1] A public project's owner, who related a public US to an epic from her own public project. She just + # has privileges to see her public related USs, and is a simple registered user regarding the private project. + # [index2] An unprivileged private member, whose role doesn't have access to the private project's USs, + # but is able to view the related-USs from the public project's. + # [index3] A private project's owner, who linked her private US to an epic from the public project. + She has privileges to see any related USs. + """ + users = [AnonymousUser(), # [index 0] + public_project_owner, # [index 1] + not_qualified_private_project_member, # [index 2] + private_project_owner] # [index 3] + + timeline_counters = _helper_get_timelines_for_accessing_users(public_project, users) + assert timeline_counters['project_timelines'] == [0, 1, 1, 2] + assert timeline_counters['user_timelines'] == { + # An anonymous user verifies the number of 'epics.relateduserstory' entries in the other users timelines + # She can't any epic related US on neither of [index1], [index2], or [index3] timelines + 0: [0, 0, 0], + # An [index1] user verifies the number of 'epics.relateduserstory' entries in the other users timelines + # She can just see the public epic related USs on her own [index1] timeline + 1: [1, 0, 0], + # An [index2] user verifies the number of 'epics.relateduserstory' entries in the other users timelines + # She can just see the public epic related USs on her own [index1] timeline + 2: [1, 0, 0], + # An [index3] user verifies the number of 'epics.relateduserstory' entries in the other users timelines + # She can see both the public epic related USs in [index1] timeline, and in her own [index3] timeline + 3: [1, 0, 1] + } + + +def _helper_get_timelines_for_accessing_users(project, users): + """ + Get the number of timeline entries (of 'epics.relateduserstory' type) that the accessing users are able to see, + for both a given project's timeline and the user's timelines + :param project: the project with the epic which contains the related user stories + :param users: both the accessing users, and the users from which recover their (user) timelines + :return: Dict with counters for 'epics.relateduserstory' entries for both the (project) and (users) + timelines, according to the accessing users privileges + """ + timeline_counts = {'project_timelines': [], 'user_timelines': {}} + # An anonymous user doesn't have a timeline to be recovered + timeline_users = list(filter(lambda au: au != AnonymousUser(), users)) + + for accessing_user in users: + project_timeline = service.get_project_timeline(project, accessing_user) + project_timeline = project_timeline.exclude(event_type__in=["projects.membership.create"]) + + timeline_counts['project_timelines'].append(project_timeline.count()) + timeline_counts['user_timelines'][users.index(accessing_user)] = [] + + for user in timeline_users: + user_timeline = service.get_user_timeline(user, accessing_user) + user_timeline = user_timeline.exclude(event_type__in=["users.user.create", "projects.membership.create"]) + + timeline_counts['user_timelines'][users.index(accessing_user)].append(user_timeline.count()) + + return timeline_counts diff --git a/tests/integration/test_totals_projects.py b/tests/integration/test_totals_projects.py new file mode 100644 index 000000000..b8b44a188 --- /dev/null +++ b/tests/integration/test_totals_projects.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +import datetime + +from .. import factories as f + +from taiga.projects.history.choices import HistoryType +from taiga.projects.models import Project + +from django.urls import reverse +from django.utils import timezone + +pytestmark = pytest.mark.django_db + + +def test_project_totals_updated_on_activity(client): + project = f.create_project() + totals_updated_datetime = project.totals_updated_datetime + now = timezone.now() + assert project.total_activity == 0 + + totals_updated_datetime = project.totals_updated_datetime + us = f.UserStoryFactory.create(project=project, owner=project.owner) + f.HistoryEntryFactory.create( + project=project, + user={"pk": project.owner.id}, + comment="", + type=HistoryType.change, + key="userstories.userstory:{}".format(us.id), + is_hidden=False, + diff=[], + created_at=now - datetime.timedelta(days=3) + ) + + project = Project.objects.get(id=project.id) + assert project.total_activity == 1 + assert project.total_activity_last_week == 1 + assert project.total_activity_last_month == 1 + assert project.total_activity_last_year == 1 + assert project.totals_updated_datetime > totals_updated_datetime + + totals_updated_datetime = project.totals_updated_datetime + f.HistoryEntryFactory.create( + project=project, + user={"pk": project.owner.id}, + comment="", + type=HistoryType.change, + key="userstories.userstory:{}".format(us.id), + is_hidden=False, + diff=[], + created_at=now - datetime.timedelta(days=13) + ) + + project = Project.objects.get(id=project.id) + assert project.total_activity == 2 + assert project.total_activity_last_week == 1 + assert project.total_activity_last_month == 2 + assert project.total_activity_last_year == 2 + assert project.totals_updated_datetime > totals_updated_datetime + + totals_updated_datetime = project.totals_updated_datetime + f.HistoryEntryFactory.create( + project=project, + user={"pk": project.owner.id}, + comment="", + type=HistoryType.change, + key="userstories.userstory:{}".format(us.id), + is_hidden=False, + diff=[], + created_at=now - datetime.timedelta(days=33) + ) + + project = Project.objects.get(id=project.id) + assert project.total_activity == 3 + assert project.total_activity_last_week == 1 + assert project.total_activity_last_month == 2 + assert project.total_activity_last_year == 3 + assert project.totals_updated_datetime > totals_updated_datetime + + totals_updated_datetime = project.totals_updated_datetime + f.HistoryEntryFactory.create( + project=project, + user={"pk": project.owner.id}, + comment="", + type=HistoryType.change, + key="userstories.userstory:{}".format(us.id), + is_hidden=False, + diff=[], + created_at=now - datetime.timedelta(days=380) + ) + + project = Project.objects.get(id=project.id) + assert project.total_activity == 4 + assert project.total_activity_last_week == 1 + assert project.total_activity_last_month == 2 + assert project.total_activity_last_year == 3 + assert project.totals_updated_datetime > totals_updated_datetime + + + +def test_project_totals_updated_on_like(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + + totals_updated_datetime = project.totals_updated_datetime + now = timezone.now() + assert project.total_activity == 0 + + now = timezone.now() + totals_updated_datetime = project.totals_updated_datetime + us = f.UserStoryFactory.create(project=project, owner=project.owner) + + l = f.LikeFactory.create(content_object=project) + l.created_date=now-datetime.timedelta(days=13) + l.save() + + l = f.LikeFactory.create(content_object=project) + l.created_date=now-datetime.timedelta(days=33) + l.save() + + l = f.LikeFactory.create(content_object=project) + l.created_date=now-datetime.timedelta(days=633) + l.save() + + project.refresh_totals() + project = Project.objects.get(id=project.id) + + assert project.total_fans == 3 + assert project.total_fans_last_week == 0 + assert project.total_fans_last_month == 1 + assert project.total_fans_last_year == 2 + assert project.totals_updated_datetime > totals_updated_datetime + + client.login(project.owner) + url_like = reverse("projects-like", args=(project.id,)) + response = client.post(url_like) + + project = Project.objects.get(id=project.id) + assert project.total_fans == 4 + assert project.total_fans_last_week == 1 + assert project.total_fans_last_month == 2 + assert project.total_fans_last_year == 3 + assert project.totals_updated_datetime > totals_updated_datetime diff --git a/tests/integration/test_us_autoclosing.py b/tests/integration/test_us_autoclosing.py new file mode 100644 index 000000000..b96d83a98 --- /dev/null +++ b/tests/integration/test_us_autoclosing.py @@ -0,0 +1,263 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task + +from tests import factories as f +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.us_closed_status = f.UserStoryStatusFactory(is_closed=True) + m.us_open_status = f.UserStoryStatusFactory(is_closed=False) + m.task_closed_status = f.TaskStatusFactory(is_closed=True) + m.task_open_status = f.TaskStatusFactory(is_closed=False) + m.user_story1 = f.UserStoryFactory(status=m.us_open_status) + m.user_story2 = f.UserStoryFactory(status=m.us_open_status) + m.task1 = f.TaskFactory(user_story=m.user_story1, status=m.task_open_status) + m.task2 = f.TaskFactory(user_story=m.user_story1, status=m.task_open_status) + m.task3 = f.TaskFactory(user_story=m.user_story1, status=m.task_open_status) + return m + + +def test_auto_close_us_when_change_us_status_to_closed_without_tasks(data): + assert data.user_story2.is_closed is False + data.user_story2.status = data.us_closed_status + data.user_story2.save() + data.user_story2 = UserStory.objects.get(pk=data.user_story2.pk) + assert data.user_story2.is_closed is True + data.user_story2.status = data.us_open_status + data.user_story2.save() + data.user_story2 = UserStory.objects.get(pk=data.user_story2.pk) + assert data.user_story2.is_closed is False + + +def test_noop_when_change_us_status_to_closed_with_open_tasks(data): + assert data.user_story1.is_closed is False + data.user_story1.status = data.us_closed_status + data.user_story1.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + data.user_story1.status = data.us_open_status + data.user_story1.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + +def test_auto_close_us_with_closed_state_when_all_tasks_are_deleted(data): + data.user_story1.status = data.us_closed_status + data.user_story1.save() + + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + data.task3.delete() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + data.task2.delete() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + data.task1.delete() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + +def test_auto_open_us_with_open_status_when_all_tasks_are_deleted(data): + data.task1.status = data.task_closed_status + data.task1.save() + data.task2.status = data.task_closed_status + data.task2.save() + data.task3.status = data.task_closed_status + data.task3.save() + + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + data.task3.delete() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + data.task2.delete() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + data.task1.delete() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + +def test_auto_open_us_with_open_status_when_all_task_are_moved_to_another_us(data): + data.task1.status = data.task_closed_status + data.task1.save() + data.task2.status = data.task_closed_status + data.task2.save() + data.task3.status = data.task_closed_status + data.task3.save() + + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + data.task3.user_story = data.user_story2 + data.task3.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + data.task2.user_story = data.user_story2 + data.task2.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + data.task1.user_story = data.user_story2 + data.task1.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + +def test_auto_close_us_closed_status_when_all_tasks_are_moved_to_another_us(data): + data.user_story1.status = data.us_closed_status + data.user_story1.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + data.task3.user_story = data.user_story2 + data.task3.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + data.task2.user_story = data.user_story2 + data.task2.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + data.task1.user_story = data.user_story2 + data.task1.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + +def test_auto_close_us_when_tasks_are_gradually_reopened(data): + data.task1.status = data.task_closed_status + data.task1.save() + data.task2.status = data.task_closed_status + data.task2.save() + data.task3.status = data.task_closed_status + data.task3.save() + + assert data.user_story1.is_closed is True + + data.task3.status = data.task_open_status + data.task3.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + data.task2.status = data.task_open_status + data.task2.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + data.task1.status = data.task_open_status + data.task1.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + +def test_auto_close_us_after_open_task_is_deleted(data): + """ + User story should be in closed state after + delete the unique open task. + """ + data.task1.status = data.task_closed_status + data.task1.save() + data.task2.status = data.task_closed_status + data.task2.save() + assert data.user_story1.is_closed is False + data.task3.delete() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + +def test_auto_close_userstory_with_milestone_when_task_and_milestone_are_removed(data): + milestone = f.MilestoneFactory.create() + + data.task1.status = data.task_closed_status + data.task1.milestone = milestone + data.task1.save() + data.task2.status = data.task_closed_status + data.task2.milestone = milestone + data.task2.save() + data.task3.status = data.task_open_status + data.task3.milestone = milestone + data.task3.save() + data.user_story1.milestone = milestone + data.user_story1.save() + + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + data.task3 = Task.objects.get(pk=data.task3.pk) + milestone.delete() + data.task3.delete() + + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + +def test_auto_close_us_when_all_tasks_are_changed_to_close_status(data): + data.task1.status = data.task_closed_status + data.task1.save() + data.task2.status = data.task_closed_status + data.task2.save() + assert data.user_story1.is_closed is False + data.task3.user_story = data.user_story2 + data.task3.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + +def test_auto_open_us_when_add_open_task(data): + data.task1.status = data.task_closed_status + data.task1.save() + data.task2.status = data.task_closed_status + data.task2.save() + data.task3.user_story = data.user_story2 + data.task3.save() + + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + data.task3.user_story = data.user_story1 + data.task3.save() + + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + +def test_task_create(data): + data.task1.status = data.task_closed_status + data.task1.save() + data.task2.status = data.task_closed_status + data.task2.save() + data.task3.status = data.task_closed_status + data.task3.save() + + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + f.TaskFactory(user_story=data.user_story1, status=data.task_closed_status) + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is True + + f.TaskFactory(user_story=data.user_story1, status=data.task_open_status) + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py new file mode 100644 index 000000000..d9d4d97f2 --- /dev/null +++ b/tests/integration/test_users.py @@ -0,0 +1,1164 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import datetime +from tempfile import NamedTemporaryFile + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from django.core.files import File +from django.core.cache import cache as default_cache + +from .. import factories as f +from ..utils import DUMMY_BMP_DATA + +from taiga.base.utils import json +from taiga.base.utils.thumbnails import get_thumbnail_url +from taiga.base.utils.dicts import into_namedtuple +from taiga.users import models +from taiga.users.serializers import LikedObjectSerializer, VotedObjectSerializer +from taiga.auth.tokens import AccessToken, CancelToken +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects import choices as project_choices +from taiga.users.services import get_watched_list, get_voted_list, get_liked_list +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import NotifyPolicy + +from easy_thumbnails.files import generate_all_aliases, get_thumbnailer + +import os + +pytestmark = pytest.mark.django_db + + +############################## +## Create user +############################## + +def test_users_create_through_standard_api(client): + user = f.UserFactory.create(is_superuser=True) + + url = reverse('users-list') + data = {} + + response = client.post(url, json.dumps(data), content_type="application/json") + assert response.status_code == 405 + + client.login(user) + + response = client.post(url, json.dumps(data), content_type="application/json") + assert response.status_code == 405 + + +############################## +## Test sanitize full name +############################## + +INVALID_NAMES = [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod", + "an example", + "http://testdomain.com", + "https://testdomain.com", + "Visit http://testdomain.com", +] + +@pytest.mark.parametrize("full_name", INVALID_NAMES) +def test_sanitize_invalid_user_full_name(client, full_name): + user = f.UserFactory.create(full_name="test_name") + url = reverse('users-detail', kwargs={"pk": user.pk}) + + client.login(user) + data = {"full_name": full_name} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + +VALID_NAMES = [ + "martin seamus mcfly" +] + +@pytest.mark.parametrize("full_name", VALID_NAMES) +def test_sanitize_valid_user_full_name(client, full_name): + user = f.UserFactory.create(full_name="test_name") + url = reverse('users-detail', kwargs={"pk": user.pk}) + + client.login(user) + data = {"full_name": full_name} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +############################## +## Change email +############################## + +def test_update_user_with_same_email(client): + user = f.UserFactory.create(email="same@email.com") + url = reverse('users-detail', kwargs={"pk": user.pk}) + data = {"email": "same@email.com"} + + client.login(user) + response = client.patch(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + assert response.data['_error_message'] == 'Duplicated email' + + user.refresh_from_db() + assert user.email == "same@email.com" + + +def test_update_user_with_duplicated_email(client): + f.UserFactory.create(email="one@email.com") + user = f.UserFactory.create(email="two@email.com") + url = reverse('users-detail', kwargs={"pk": user.pk}) + data = {"email": "one@email.com"} + + client.login(user) + response = client.patch(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + assert response.data['_error_message'] == 'Duplicated email' + + user.refresh_from_db() + assert user.email == "two@email.com" + + +def test_update_user_with_invalid_email(client): + user = f.UserFactory.create(email="my@email.com") + url = reverse('users-detail', kwargs={"pk": user.pk}) + data = {"email": "my@email"} + + client.login(user) + response = client.patch(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + assert response.data['_error_message'] == 'Invalid email' + + user.refresh_from_db() + assert user.email == "my@email.com" + + +def test_update_user_with_unallowed_domain_email(client, settings): + settings.USER_EMAIL_ALLOWED_DOMAINS = ['email.com'] + user = f.UserFactory.create(email="my@email.com") + url = reverse('users-detail', kwargs={"pk": user.pk}) + data = {"email": "my@invalid-email.com"} + + client.login(user) + response = client.patch(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + assert response.data['_error_message'] == 'Invalid email' + + user.refresh_from_db() + assert user.email == "my@email.com" + +def test_update_user_with_allowed_domain_email(client, settings): + settings.USER_EMAIL_ALLOWED_DOMAINS = ['email.com'] + + user = f.UserFactory.create(email="old@email.com") + url = reverse('users-detail', kwargs={"pk": user.pk}) + data = {"email": "new@email.com"} + + client.login(user) + response = client.patch(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 200 + + user.refresh_from_db() + assert user.email == "old@email.com" + assert user.email_token is not None + assert user.new_email == "new@email.com" + + +def test_update_user_with_valid_email(client): + user = f.UserFactory.create(email="old@email.com") + url = reverse('users-detail', kwargs={"pk": user.pk}) + data = {"email": "new@email.com"} + + client.login(user) + response = client.patch(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 200 + user.refresh_from_db() + assert user.email == "old@email.com" + assert user.email_token is not None + assert user.new_email == "new@email.com" + + +def test_validate_requested_email_change(client): + user = f.UserFactory.create(email="old@email.com", email_token="change_email_token", new_email="new@email.com") + url = reverse('users-change-email') + data = {"email_token": "change_email_token"} + + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 204 + user.refresh_from_db() + assert user.email_token is None + assert user.new_email is None + assert user.email == "new@email.com" + + +def test_validate_requested_email_change_for_anonymous_user(client): + user = f.UserFactory.create(email="old@email.com", email_token="change_email_token", new_email="new@email.com") + url = reverse('users-change-email') + data = {"email_token": "change_email_token"} + + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 204 + user.refresh_from_db() + assert user.email_token is None + assert user.new_email is None + assert user.email == "new@email.com" + + +def test_validate_requested_email_change_without_token(client): + user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com") + url = reverse('users-change-email') + data = {} + + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + + +def test_validate_requested_email_change_with_invalid_token(client): + user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com") + url = reverse('users-change-email') + data = {"email_token": "invalid_email_token"} + + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + + +def test_validate_requested_email_change_for_anonymous_user_and_reset_onpremise_newsletter_form_subscriptions(client): + user = f.UserFactory.create(email="old@email.com", email_token="change_email_token", new_email="new@email.com") + user.storage_entries.create(key="dont_ask_premise_newsletter", value=True) + + assert user.storage_entries.count() == 1 + + url = reverse('users-change-email') + data = {"email_token": "change_email_token"} + + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 204 + user.refresh_from_db() + assert user.email_token is None + assert user.new_email is None + assert user.email == "new@email.com" + assert user.storage_entries.count() == 0 + +############################## +## Delete user +############################## + +def test_delete_self_user(client): + user = f.UserFactory.create() + url = reverse('users-detail', kwargs={"pk": user.pk}) + + client.login(user) + response = client.delete(url) + + assert response.status_code == 204 + user = models.User.objects.get(pk=user.id) + assert user.full_name == "Deleted user" + + +def test_delete_self_user_with_date_cancelled(client): + user = f.UserFactory.create() + url = reverse('users-detail', kwargs={"pk": user.pk}) + + client.login(user) + response = client.delete(url) + + assert response.status_code == 204 + user = models.User.objects.get(pk=user.id) + assert user.full_name == "Deleted user" + + date_cancelled = datetime.date(user.date_cancelled.year, user.date_cancelled.month, user.date_cancelled.day) + date_now = datetime.date.today() + assert date_cancelled == date_now + + +def test_delete_self_user_blocking_projects(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + url = reverse('users-detail', kwargs={"pk": user.pk}) + + assert project.blocked_code == None + client.login(user) + response = client.delete(url) + project = user.owned_projects.first() + assert project.blocked_code == project_choices.BLOCKED_BY_OWNER_LEAVING + + +def test_delete_self_user_remove_membership_projects(client): + project = f.ProjectFactory.create() + user = f.UserFactory.create() + f.create_membership(project=project, user=user) + + url = reverse('users-detail', kwargs={"pk": user.pk}) + + assert project.memberships.all().count() == 1 + + client.login(user) + response = client.delete(url) + + assert project.memberships.all().count() == 0 + + +def test_deleted_user_can_not_use_its_token(client): + user = f.UserFactory.create() + token = AccessToken.for_user(user) + + headers = {'HTTP_AUTHORIZATION': f'Bearer {token}'} + url = reverse('users-me') + + response = client.get(url, **headers) + assert response.status_code == 200, response.data + + user.cancel() + + response = client.get(url, **headers) + assert response.status_code == 401, response.data + + +############################## +## Cancel account +############################## + +def test_cancel_self_user_with_valid_token(client): + user = f.UserFactory.create() + url = reverse('users-cancel') + cancel_token = CancelToken.for_user(user) + data = {"cancel_token": str(cancel_token)} + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 204 + user = models.User.objects.get(pk=user.id) + assert user.full_name == "Deleted user" + + +def test_cancel_self_user_with_valid_token_but_inactive(client): + user = f.UserFactory.create(is_active=False) + url = reverse('users-cancel') + cancel_token = CancelToken.for_user(user) + data = {"cancel_token": str(cancel_token)} + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + +def test_cancel_self_user_with_invalid_token(client): + user = f.UserFactory.create() + url = reverse('users-cancel') + data = {"cancel_token": str(CancelToken())} + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + + data = {"cancel_token": "__invalid_token__"} + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + + +def test_cancel_self_user_with_date_cancelled(client): + user = f.UserFactory.create() + cancel_token = CancelToken.for_user(user) + url = reverse('users-cancel') + data = {"cancel_token": str(cancel_token)} + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 204 + user = models.User.objects.get(pk=user.id) + assert user.full_name == "Deleted user" + + date_cancelled = datetime.date(user.date_cancelled.year, user.date_cancelled.month, user.date_cancelled.day) + date_now = datetime.date.today() + assert date_cancelled == date_now + + +############################## +## Avatar +############################## + +def test_change_avatar(client): + url = reverse('users-change-avatar') + + user = f.UserFactory() + client.login(user) + + with NamedTemporaryFile() as avatar: + # Test no avatar send + post_data = {} + response = client.post(url, post_data) + assert response.status_code == 400 + + # Test invalid file send + post_data = { + 'avatar': avatar + } + response = client.post(url, post_data) + assert response.status_code == 400 + + # Test empty valid avatar send + avatar.write(DUMMY_BMP_DATA) + avatar.seek(0) + response = client.post(url, post_data) + assert response.status_code == 200 + + +def test_change_avatar_with_long_file_name(client): + url = reverse('users-change-avatar') + user = f.UserFactory() + + with NamedTemporaryFile(delete=False) as avatar: + avatar.name=500*"x"+".bmp" + avatar.write(DUMMY_BMP_DATA) + avatar.seek(0) + + client.login(user) + post_data = {'avatar': avatar} + response = client.post(url, post_data) + + assert response.status_code == 200 + + +@pytest.mark.django_db(transaction=True) +def test_change_avatar_removes_the_old_one(client): + url = reverse('users-change-avatar') + user = f.UserFactory() + + with NamedTemporaryFile(delete=False) as avatar: + avatar.write(DUMMY_BMP_DATA) + avatar.seek(0) + user.photo = File(avatar) + user.save() + generate_all_aliases(user.photo, include_global=True) + + with NamedTemporaryFile(delete=False) as avatar: + thumbnailer = get_thumbnailer(user.photo) + original_photo_paths = [user.photo.path] + original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] + assert all(list(map(os.path.exists, original_photo_paths))) + + client.login(user) + avatar.write(DUMMY_BMP_DATA) + avatar.seek(0) + post_data = {'avatar': avatar} + response = client.post(url, post_data) + + assert response.status_code == 200 + assert not any(list(map(os.path.exists, original_photo_paths))) + + +@pytest.mark.django_db(transaction=True) +def test_remove_avatar(client): + url = reverse('users-remove-avatar') + user = f.UserFactory() + + with NamedTemporaryFile(delete=False) as avatar: + avatar.write(DUMMY_BMP_DATA) + avatar.seek(0) + user.photo = File(avatar) + user.save() + generate_all_aliases(user.photo, include_global=True) + + thumbnailer = get_thumbnailer(user.photo) + original_photo_paths = [user.photo.path] + original_photo_paths += [th.path for th in thumbnailer.get_thumbnails()] + assert all(list(map(os.path.exists, original_photo_paths))) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + assert not any(list(map(os.path.exists, original_photo_paths))) + + +############################## +## Contacts +############################## + +def test_list_contacts_private_projects(client): + project = f.ProjectFactory.create() + user_1 = f.UserFactory.create() + user_2 = f.UserFactory.create() + role = f.RoleFactory(project=project, permissions=["view_project"]) + membership_1 = f.MembershipFactory.create(project=project, user=user_1, role=role) + membership_2 = f.MembershipFactory.create(project=project, user=user_2, role=role) + + url = reverse('users-contacts', kwargs={"pk": user_1.pk}) + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + response_content = response.data + assert len(response_content) == 0 + + client.login(user_1) + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + response_content = response.data + assert len(response_content) == 1 + assert response_content[0]["id"] == user_2.id + + +def test_list_contacts_no_projects(client): + user_1 = f.UserFactory.create() + user_2 = f.UserFactory.create() + role_1 = f.RoleFactory(permissions=["view_project"]) + role_2 = f.RoleFactory(permissions=["view_project"]) + membership_1 = f.MembershipFactory.create(project=role_1.project, user=user_1, role=role_1) + membership_2 = f.MembershipFactory.create(project=role_2.project, user=user_2, role=role_2) + + client.login(user_1) + + url = reverse('users-contacts', kwargs={"pk": user_1.pk}) + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + + response_content = response.data + assert len(response_content) == 0 + + +def test_list_contacts_public_projects(client): + project = f.ProjectFactory.create(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + + user_1 = f.UserFactory.create() + user_2 = f.UserFactory.create() + role = f.RoleFactory(project=project) + membership_1 = f.MembershipFactory.create(project=project, user=user_1, role=role) + membership_2 = f.MembershipFactory.create(project=project, user=user_2, role=role) + + url = reverse('users-contacts', kwargs={"pk": user_1.pk}) + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + + response_content = response.data + assert len(response_content) == 1 + assert response_content[0]["id"] == user_2.id + + +def test_list_contacts_filter_exclude_project(client): + project1 = f.ProjectFactory.create() + project2 = f.ProjectFactory.create() + user_1 = f.UserFactory.create() + user_2 = f.UserFactory.create() + user_3 = f.UserFactory.create() + user_4 = f.UserFactory.create() + role1 = f.RoleFactory(project=project1, permissions=["view_project"]) + role2 = f.RoleFactory(project=project2, permissions=["view_project"]) + + membership_11 = f.MembershipFactory.create(project=project1, user=user_1, role=role1) + membership_12 = f.MembershipFactory.create(project=project1, user=user_2, role=role1) + + membership_21 = f.MembershipFactory.create(project=project2, user=user_1, role=role2) + membership_23 = f.MembershipFactory.create(project=project2, user=user_3, role=role2) + membership_24 = f.MembershipFactory.create(project=project2, user=user_4, role=role2) + + url = reverse('users-contacts', kwargs={"pk": user_1.pk}) + + client.login(user_1) + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + response_content = response.data + assert len(response_content) == 3 + + response = client.get(url + "?exclude_project={}".format(project1.id), content_type="application/json") + assert response.status_code == 200 + response_content = response.data + assert len(response_content) == 2 + + response = client.get(url + "?exclude_project={}".format(project2.id), content_type="application/json") + assert response.status_code == 200 + response_content = response.data + assert len(response_content) == 1 + + +############################## +## Mail permissions +############################## + +def test_mail_permissions(client): + user_1 = f.UserFactory.create(is_superuser=True) + user_2 = f.UserFactory.create() + + url1 = reverse('users-detail', kwargs={"pk": user_1.pk}) + url2 = reverse('users-detail', kwargs={"pk": user_2.pk}) + + # Anonymous user + response = client.json.get(url1) + assert response.status_code == 200 + assert "email" not in response.data + + response = client.json.get(url2) + assert response.status_code == 200 + assert "email" not in response.data + + # Superuser + client.login(user_1) + + response = client.json.get(url1) + assert response.status_code == 200 + assert "email" in response.data + + response = client.json.get(url2) + assert response.status_code == 200 + assert "email" in response.data + + # Normal user + client.login(user_2) + + response = client.json.get(url1) + assert response.status_code == 200 + assert "email" not in response.data + + response = client.json.get(url2) + assert response.status_code == 200 + assert "email" in response.data + + +############################## +## Watchers, Likes and Votes +############################## + +def test_get_watched_list(): + fav_user = f.UserFactory() + viewer_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fav_user) + project.add_watcher(fav_user) + + epic = f.EpicFactory(project=project, subject="Testing epic") + epic.add_watcher(fav_user) + + user_story = f.UserStoryFactory(project=project, subject="Testing user story") + user_story.add_watcher(fav_user) + + task = f.TaskFactory(project=project, subject="Testing task") + task.add_watcher(fav_user) + + issue = f.IssueFactory(project=project, subject="Testing issue") + issue.add_watcher(fav_user) + + assert len(get_watched_list(fav_user, viewer_user)) == 5 + assert len(get_watched_list(fav_user, viewer_user, type="project")) == 1 + assert len(get_watched_list(fav_user, viewer_user, type="userstory")) == 1 + assert len(get_watched_list(fav_user, viewer_user, type="task")) == 1 + assert len(get_watched_list(fav_user, viewer_user, type="issue")) == 1 + assert len(get_watched_list(fav_user, viewer_user, type="epic")) == 1 + assert len(get_watched_list(fav_user, viewer_user, type="unknown")) == 0 + + assert len(get_watched_list(fav_user, viewer_user, q="issue")) == 1 + assert len(get_watched_list(fav_user, viewer_user, q="unexisting text")) == 0 + + +def test_get_liked_list(): + fan_user = f.UserFactory() + viewer_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fan_user) + content_type = ContentType.objects.get_for_model(project) + f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) + + assert len(get_liked_list(fan_user, viewer_user)) == 1 + assert len(get_liked_list(fan_user, viewer_user, type="project")) == 1 + assert len(get_liked_list(fan_user, viewer_user, type="unknown")) == 0 + + assert len(get_liked_list(fan_user, viewer_user, q="project")) == 1 + assert len(get_liked_list(fan_user, viewer_user, q="unexisting text")) == 0 + + +def test_get_voted_list(): + fav_user = f.UserFactory() + viewer_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fav_user) + + epic = f.EpicFactory(project=project, subject="Testing epic") + content_type = ContentType.objects.get_for_model(epic) + f.VoteFactory(content_type=content_type, object_id=epic.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=epic.id, count=1) + + user_story = f.UserStoryFactory(project=project, subject="Testing user story") + content_type = ContentType.objects.get_for_model(user_story) + f.VoteFactory(content_type=content_type, object_id=user_story.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=user_story.id, count=1) + + task = f.TaskFactory(project=project, subject="Testing task") + content_type = ContentType.objects.get_for_model(task) + f.VoteFactory(content_type=content_type, object_id=task.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=task.id, count=1) + + issue = f.IssueFactory(project=project, subject="Testing issue") + content_type = ContentType.objects.get_for_model(issue) + f.VoteFactory(content_type=content_type, object_id=issue.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=issue.id, count=1) + + assert len(get_voted_list(fav_user, viewer_user)) == 4 + assert len(get_voted_list(fav_user, viewer_user, type="epic")) == 1 + assert len(get_voted_list(fav_user, viewer_user, type="userstory")) == 1 + assert len(get_voted_list(fav_user, viewer_user, type="task")) == 1 + assert len(get_voted_list(fav_user, viewer_user, type="issue")) == 1 + assert len(get_voted_list(fav_user, viewer_user, type="unknown")) == 0 + + assert len(get_voted_list(fav_user, viewer_user, q="issue")) == 1 + assert len(get_voted_list(fav_user, viewer_user, q="unexisting text")) == 0 + + +def test_get_watched_list_valid_info_for_project(): + fav_user = f.UserFactory() + viewer_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_epic", "view_us", "view_tasks", "view_issues"]) + project.add_watcher(fav_user) + + raw_project_watch_info = get_watched_list(fav_user, viewer_user)[0] + + project_watch_info = LikedObjectSerializer(into_namedtuple(raw_project_watch_info)).data + + assert project_watch_info["type"] == "project" + assert project_watch_info["id"] == project.id + assert project_watch_info["ref"] == None + assert project_watch_info["slug"] == project.slug + assert project_watch_info["name"] == project.name + assert project_watch_info["subject"] == None + assert project_watch_info["description"] == project.description + assert project_watch_info["assigned_to"] == None + assert project_watch_info["status"] == None + assert project_watch_info["status_color"] == None + assert project_watch_info["is_private"] == project.is_private + assert project_watch_info["logo_small_url"] == get_thumbnail_url(project.logo, settings.THN_LOGO_SMALL) + assert project_watch_info["is_fan"] == False + assert project_watch_info["is_watcher"] == False + assert project_watch_info["total_watchers"] == 1 + assert project_watch_info["total_fans"] == 0 + assert project_watch_info["project"] == None + assert project_watch_info["project_name"] == None + assert project_watch_info["project_slug"] == None + assert project_watch_info["project_is_private"] == None + assert project_watch_info["project_blocked_code"] == None + assert project_watch_info["assigned_to"] == None + assert project_watch_info["assigned_to_extra_info"] == None + + +def test_get_watched_list_for_project_with_ignored_notify_level(): + #If the notify policy level is ignore the project shouldn't be in the watched results + fav_user = f.UserFactory() + viewer_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project", tags=['test', 'tag']) + role = f.RoleFactory(project=project, permissions=["view_project", "view_epic", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fav_user) + notify_policy = NotifyPolicy.objects.get(user=fav_user, project=project) + notify_policy.notify_level=NotifyLevel.none + notify_policy.save() + + watched_list = get_watched_list(fav_user, viewer_user) + assert len(watched_list) == 0 + + +def test_get_liked_list_valid_info(): + fan_user = f.UserFactory() + viewer_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + content_type = ContentType.objects.get_for_model(project) + like = f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) + project.refresh_totals() + + raw_project_like_info = get_liked_list(fan_user, viewer_user)[0] + project_like_info = LikedObjectSerializer(into_namedtuple(raw_project_like_info)).data + + assert project_like_info["type"] == "project" + assert project_like_info["id"] == project.id + assert project_like_info["ref"] == None + assert project_like_info["slug"] == project.slug + assert project_like_info["name"] == project.name + assert project_like_info["subject"] == None + assert project_like_info["description"] == project.description + assert project_like_info["assigned_to"] == None + assert project_like_info["status"] == None + assert project_like_info["status_color"] == None + assert project_like_info["is_private"] == project.is_private + assert project_like_info["logo_small_url"] == get_thumbnail_url(project.logo, settings.THN_LOGO_SMALL) + + assert project_like_info["is_fan"] == False + assert project_like_info["is_watcher"] == False + assert project_like_info["total_watchers"] == 0 + assert project_like_info["total_fans"] == 1 + assert project_like_info["project"] == None + assert project_like_info["project_name"] == None + assert project_like_info["project_slug"] == None + assert project_like_info["project_is_private"] == None + assert project_like_info["project_blocked_code"] == None + assert project_like_info["assigned_to"] == None + assert project_like_info["assigned_to_extra_info"] == None + + +def test_get_watched_list_valid_info_for_not_project_types(): + fav_user = f.UserFactory() + viewer_user = f.UserFactory() + assigned_to_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + + factories = { + "epic": f.EpicFactory, + "userstory": f.UserStoryFactory, + "task": f.TaskFactory, + "issue": f.IssueFactory + } + + for object_type in factories: + instance = factories[object_type](project=project, + subject="Testing", + tags=["test1", "test2"], + assigned_to=assigned_to_user) + + instance.add_watcher(fav_user) + raw_instance_watch_info = get_watched_list(fav_user, viewer_user, type=object_type)[0] + instance_watch_info = VotedObjectSerializer(into_namedtuple(raw_instance_watch_info)).data + + assert instance_watch_info["type"] == object_type + assert instance_watch_info["id"] == instance.id + assert instance_watch_info["ref"] == instance.ref + assert instance_watch_info["slug"] == None + assert instance_watch_info["name"] == None + assert instance_watch_info["subject"] == instance.subject + assert instance_watch_info["description"] == None + assert instance_watch_info["assigned_to"] == instance.assigned_to.id + assert instance_watch_info["status"] == instance.status.name + assert instance_watch_info["status_color"] == instance.status.color + + tags_colors = {tc["name"]:tc["color"] for tc in instance_watch_info["tags_colors"]} + assert "test1" in tags_colors + assert "test2" in tags_colors + + assert instance_watch_info["is_private"] == None + assert instance_watch_info["logo_small_url"] == None + assert instance_watch_info["is_voter"] == False + assert instance_watch_info["is_watcher"] == False + assert instance_watch_info["total_watchers"] == 1 + assert instance_watch_info["total_voters"] == 0 + assert instance_watch_info["project"] == instance.project.id + assert instance_watch_info["project_name"] == instance.project.name + assert instance_watch_info["project_slug"] == instance.project.slug + assert instance_watch_info["project_is_private"] == instance.project.is_private + assert instance_watch_info["project_blocked_code"] == instance.project.blocked_code + assert instance_watch_info["assigned_to"] != None + assert instance_watch_info["assigned_to_extra_info"]["username"] == instance.assigned_to.username + assert instance_watch_info["assigned_to_extra_info"]["full_name_display"] == instance.assigned_to.get_full_name() + assert instance_watch_info["assigned_to_extra_info"]["photo"] == None + assert instance_watch_info["assigned_to_extra_info"]["gravatar_id"] != None + + +def test_get_voted_list_valid_info(): + fav_user = f.UserFactory() + viewer_user = f.UserFactory() + assigned_to_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + + factories = { + "epic": f.EpicFactory, + "userstory": f.UserStoryFactory, + "task": f.TaskFactory, + "issue": f.IssueFactory + } + + for object_type in factories: + instance = factories[object_type](project=project, + subject="Testing", + tags=["test1", "test2"], + assigned_to=assigned_to_user) + + content_type = ContentType.objects.get_for_model(instance) + vote = f.VoteFactory(content_type=content_type, object_id=instance.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=instance.id, count=3) + + raw_instance_vote_info = get_voted_list(fav_user, viewer_user, type=object_type)[0] + instance_vote_info = VotedObjectSerializer(into_namedtuple(raw_instance_vote_info)).data + + assert instance_vote_info["type"] == object_type + assert instance_vote_info["id"] == instance.id + assert instance_vote_info["ref"] == instance.ref + assert instance_vote_info["slug"] == None + assert instance_vote_info["name"] == None + assert instance_vote_info["subject"] == instance.subject + assert instance_vote_info["description"] == None + assert instance_vote_info["assigned_to"] == instance.assigned_to.id + assert instance_vote_info["status"] == instance.status.name + assert instance_vote_info["status_color"] == instance.status.color + + tags_colors = {tc["name"]:tc["color"] for tc in instance_vote_info["tags_colors"]} + assert "test1" in tags_colors + assert "test2" in tags_colors + + assert instance_vote_info["is_private"] == None + assert instance_vote_info["logo_small_url"] == None + assert instance_vote_info["is_voter"] == False + assert instance_vote_info["is_watcher"] == False + assert instance_vote_info["total_watchers"] == 0 + assert instance_vote_info["total_voters"] == 3 + assert instance_vote_info["project"] == instance.project.id + assert instance_vote_info["project_name"] == instance.project.name + assert instance_vote_info["project_slug"] == instance.project.slug + assert instance_vote_info["project_is_private"] == instance.project.is_private + assert instance_vote_info["project_blocked_code"] == instance.project.blocked_code + assert instance_vote_info["assigned_to"] != None + assert instance_vote_info["assigned_to_extra_info"]["username"] == instance.assigned_to.username + assert instance_vote_info["assigned_to_extra_info"]["full_name_display"] == instance.assigned_to.get_full_name() + assert instance_vote_info["assigned_to_extra_info"]["photo"] == None + assert instance_vote_info["assigned_to_extra_info"]["gravatar_id"] != None + + + +def test_get_watched_list_with_liked_and_voted_objects(client): + fav_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fav_user) + project.add_watcher(fav_user) + content_type = ContentType.objects.get_for_model(project) + f.LikeFactory(content_type=content_type, object_id=project.id, user=fav_user) + + voted_elements_factories = { + "epic": f.EpicFactory, + "userstory": f.UserStoryFactory, + "task": f.TaskFactory, + "issue": f.IssueFactory + } + + for object_type in voted_elements_factories: + instance = voted_elements_factories[object_type](project=project) + content_type = ContentType.objects.get_for_model(instance) + instance.add_watcher(fav_user) + f.VoteFactory(content_type=content_type, object_id=instance.id, user=fav_user) + + client.login(fav_user) + url = reverse('users-watched', kwargs={"pk": fav_user.pk}) + response = client.get(url, content_type="application/json") + + for element_data in response.data: + #assert element_data["is_watcher"] == True + if element_data["type"] == "project": + assert element_data["is_fan"] == True + else: + assert element_data["is_voter"] == True + + +def test_get_liked_list_with_watched_objects(client): + fav_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fav_user) + project.add_watcher(fav_user) + content_type = ContentType.objects.get_for_model(project) + f.LikeFactory(content_type=content_type, object_id=project.id, user=fav_user) + + client.login(fav_user) + url = reverse('users-liked', kwargs={"pk": fav_user.pk}) + response = client.get(url, content_type="application/json") + + element_data = response.data[0] + assert element_data["is_watcher"] == True + assert element_data["is_fan"] == True + + +def test_get_voted_list_with_watched_objects(client): + fav_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fav_user) + + voted_elements_factories = { + "epic": f.EpicFactory, + "userstory": f.UserStoryFactory, + "task": f.TaskFactory, + "issue": f.IssueFactory + } + + for object_type in voted_elements_factories: + instance = voted_elements_factories[object_type](project=project) + content_type = ContentType.objects.get_for_model(instance) + instance.add_watcher(fav_user) + f.VoteFactory(content_type=content_type, object_id=instance.id, user=fav_user) + + client.login(fav_user) + url = reverse('users-voted', kwargs={"pk": fav_user.pk}) + response = client.get(url, content_type="application/json") + + for element_data in response.data: + assert element_data["is_watcher"] == True + assert element_data["is_voter"] == True + + +def test_get_watched_list_permissions(): + fav_user = f.UserFactory() + viewer_unpriviliged_user = f.UserFactory() + viewer_priviliged_user = f.UserFactory() + + project = f.ProjectFactory(is_private=True, name="Testing project") + project.add_watcher(fav_user) + role = f.RoleFactory(project=project, permissions=["view_project", "view_epic", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=viewer_priviliged_user) + + epic = f.EpicFactory(project=project, subject="Testing epic") + epic.add_watcher(fav_user) + + user_story = f.UserStoryFactory(project=project, subject="Testing user story") + user_story.add_watcher(fav_user) + + task = f.TaskFactory(project=project, subject="Testing task") + task.add_watcher(fav_user) + + issue = f.IssueFactory(project=project, subject="Testing issue") + issue.add_watcher(fav_user) + + #If the project is private a viewer user without any permission shouldn' see + # any vote + assert len(get_watched_list(fav_user, viewer_unpriviliged_user)) == 0 + + #If the project is private but the viewer user has permissions the votes should + # be accessible + assert len(get_watched_list(fav_user, viewer_priviliged_user)) == 5 + + #If the project is private but has the required anon permissions the votes should + # be accessible by any user too + project.anon_permissions = ["view_project", "view_epic", "view_us", "view_tasks", "view_issues"] + project.save() + assert len(get_watched_list(fav_user, viewer_unpriviliged_user)) == 5 + + +def test_get_liked_list_permissions(): + fan_user = f.UserFactory() + viewer_unpriviliged_user = f.UserFactory() + viewer_priviliged_user = f.UserFactory() + + project = f.ProjectFactory(is_private=True, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=viewer_priviliged_user) + content_type = ContentType.objects.get_for_model(project) + f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) + + #If the project is private a viewer user without any permission shouldn' see + # any vote + assert len(get_liked_list(fan_user, viewer_unpriviliged_user)) == 0 + + #If the project is private but the viewer user has permissions the votes should + # be accessible + assert len(get_liked_list(fan_user, viewer_priviliged_user)) == 1 + + #If the project is private but has the required anon permissions the votes should + # be accessible by any user too + project.anon_permissions = ["view_project", "view_us", "view_tasks", "view_issues"] + project.save() + assert len(get_liked_list(fan_user, viewer_unpriviliged_user)) == 1 + + +def test_get_voted_list_permissions(): + fav_user = f.UserFactory() + viewer_unpriviliged_user = f.UserFactory() + viewer_priviliged_user = f.UserFactory() + + project = f.ProjectFactory(is_private=True, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_epic", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=viewer_priviliged_user) + + epic = f.EpicFactory(project=project, subject="Testing epic") + content_type = ContentType.objects.get_for_model(epic) + f.VoteFactory(content_type=content_type, object_id=epic.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=epic.id, count=1) + + user_story = f.UserStoryFactory(project=project, subject="Testing user story") + content_type = ContentType.objects.get_for_model(user_story) + f.VoteFactory(content_type=content_type, object_id=user_story.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=user_story.id, count=1) + + task = f.TaskFactory(project=project, subject="Testing task") + content_type = ContentType.objects.get_for_model(task) + f.VoteFactory(content_type=content_type, object_id=task.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=task.id, count=1) + + issue = f.IssueFactory(project=project, subject="Testing issue") + content_type = ContentType.objects.get_for_model(issue) + f.VoteFactory(content_type=content_type, object_id=issue.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=issue.id, count=1) + + #If the project is private a viewer user without any permission shouldn' see + # any vote + assert len(get_voted_list(fav_user, viewer_unpriviliged_user)) == 0 + + #If the project is private but the viewer user has permissions the votes should + # be accessible + assert len(get_voted_list(fav_user, viewer_priviliged_user)) == 4 + + #If the project is private but has the required anon permissions the votes should + # be accessible by any user too + project.anon_permissions = ["view_project", "view_epic", "view_us", "view_tasks", "view_issues"] + project.save() + assert len(get_voted_list(fav_user, viewer_unpriviliged_user)) == 4 + +############################## +## Retrieve user +############################## + +def test_users_retrieve_throttling_api(client): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user-detail"] = "1/minute" + + user = f.UserFactory.create() + + url = reverse('users-detail', kwargs={"pk": user.pk}) + data = {} + + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + + response = client.get(url, content_type="application/json") + assert response.status_code == 429 + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user-detail"] = None + default_cache.clear() + + +def test_users_by_username_throttling_api(client): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user-detail"] = "1/minute" + user = f.UserFactory.create(username="test-user-detail") + + url = reverse('users-by-username') + data = {} + + response = client.get(url, {"username": user.username}, content_type="application/json") + assert response.status_code == 200 + + response = client.get(url, {"username": user.username}, content_type="application/json") + assert response.status_code == 429 + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user-detail"] = None + default_cache.clear() diff --git a/tests/integration/test_userstorage_api.py b/tests/integration/test_userstorage_api.py new file mode 100644 index 000000000..8f20b5fa9 --- /dev/null +++ b/tests/integration/test_userstorage_api.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.urls import reverse +from .. import factories + +from taiga.base.utils import json + +pytestmark = pytest.mark.django_db + + +def test_list_userstorage(client): + user1 = factories.UserFactory() + user2 = factories.UserFactory() + storage11 = factories.StorageEntryFactory(owner=user1) + factories.StorageEntryFactory(owner=user1) + storage13 = factories.StorageEntryFactory(owner=user1) + factories.StorageEntryFactory(owner=user2) + + # List by anonumous user + response = client.json.get(reverse("user-storage-list")) + assert response.status_code == 200 + assert len(response.data) == 0 + + # List own entries + client.login(username=user1.username, password=user1.username) + response = client.json.get(reverse("user-storage-list")) + assert response.status_code == 200 + assert len(response.data) == 3 + + client.login(username=user2.username, password=user2.username) + response = client.json.get(reverse("user-storage-list")) + assert response.status_code == 200 + assert len(response.data) == 1 + + # Filter results by key + client.login(username=user1.username, password=user1.username) + keys = ",".join([storage11.key, storage13.key]) + url = "{}?keys={}".format(reverse("user-storage-list"), keys) + + response = client.json.get(url) + assert response.status_code == 200 + assert len(response.data) == 2 + + +def test_view_storage_entries(client): + user1 = factories.UserFactory() + user2 = factories.UserFactory() + storage11 = factories.StorageEntryFactory(owner=user1) + + # Get by anonymous user + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) + assert response.status_code == 404 + + # Get single entry + client.login(username=user1.username, password=user1.username) + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) + assert response.status_code == 200 + assert response.data["key"] == storage11.key + assert response.data["value"] == storage11.value + + # Get not existent key + client.login(username=user2.username, password=user2.username) + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) + assert response.status_code == 404 + + response = client.json.get(reverse("user-storage-detail", args=["foobar"])) + assert response.status_code == 404 + + +def test_create_entries(client): + user1 = factories.UserFactory() + storage11 = factories.StorageEntryFactory(owner=user1) + + form = {"key": "foo", + "value": {"bar": "bar"}} + form_without_key = {"value": {"bar": "bar"}} + form_without_value = {"key": "foo"} + error_form = {"key": storage11.key, + "value": {"bar": "bar"}} + + # Create entry by anonymous user + response = client.json.post(reverse("user-storage-list"), json.dumps(form)) + assert response.status_code == 401 + + # Create by logged user + client.login(username=user1.username, password=user1.username) + response = client.json.post(reverse("user-storage-list"), json.dumps(form)) + assert response.status_code == 201 + response = client.json.get(reverse("user-storage-detail", args=[form["key"]])) + assert response.status_code == 200 + + # Wrong data + client.login(username=user1.username, password=user1.username) + response = client.json.post(reverse("user-storage-list"), json.dumps(form_without_key)) + assert response.status_code == 400 + response = client.json.post(reverse("user-storage-list"), json.dumps(form_without_value)) + assert response.status_code == 400 + response = client.json.post(reverse("user-storage-list"), json.dumps(error_form)) + assert response.status_code == 400 + + +def test_update_entries(client): + user1 = factories.UserFactory() + storage11 = factories.StorageEntryFactory(owner=user1) + + # Update by anonymous user + form = {"value": "bar", "key": storage11.key} + response = client.json.put(reverse("user-storage-detail", args=[storage11.key]), + json.dumps(form)) + assert response.status_code == 401 + + # Update by logged user + client.login(username=user1.username, password=user1.username) + form = {"value": {"bar": "bar"}, "key": storage11.key} + + response = client.json.put(reverse("user-storage-detail", args=[storage11.key]), + json.dumps(form)) + assert response.status_code == 200 + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) + assert response.status_code == 200 + assert response.data["value"] == form["value"] + + # Update not existing entry + form = {"value": {"bar": "bar"}, "key": "foo"} + response = client.json.get(reverse("user-storage-detail", args=[form["key"]])) + assert response.status_code == 404 + response = client.json.put(reverse("user-storage-detail", args=[form["key"]]), + json.dumps(form)) + assert response.status_code == 404 + + +def test_delete_storage_entry(client): + user1 = factories.UserFactory() + user2 = factories.UserFactory() + storage11 = factories.StorageEntryFactory(owner=user1) + + # Delete by anonumous user + response = client.json.delete(reverse("user-storage-detail", args=[storage11.key])) + assert response.status_code == 401 + + # Delete by logged user + client.login(username=user1.username, password=user1.username) + response = client.json.delete(reverse("user-storage-detail", args=[storage11.key])) + assert response.status_code == 204 + + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) + assert response.status_code == 404 + + # Delete not existent entry + response = client.json.delete(reverse("user-storage-detail", args=["foo"])) + assert response.status_code == 404 + + client.login(username=user2.username, password=user2.username) + response = client.json.delete(reverse("user-storage-detail", args=[storage11.key])) + assert response.status_code == 404 diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py new file mode 100644 index 000000000..80db1af0d --- /dev/null +++ b/tests/integration/test_userstories.py @@ -0,0 +1,1697 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid +import csv + +from datetime import timedelta +from urllib.parse import quote + +from unittest import mock +from django.urls import reverse +from django.utils import timezone + +from taiga.base.utils import json +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.occ import OCCResourceMixin +from taiga.projects.userstories import services, models + +from .. import factories as f + +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 | Milestone | + # |----#---------#--------#-------------#----------------#---------------------#-------------------------- + # | 0 | status3 | user2 | None | None | tag1 | epic0 | None | + # | 1 | status3 | user1 | None | user1 | tag2 | None | | + # | 2 | status1 | user3 | None | None | tag1 tag2 | epic1 | None | + # | 3 | status0 | user2 | None | None | tag3 | None | | + # | 4 | status0 | user1 | user1 | None | tag1 tag2 tag3 | epic0 | None | + # | 5 | status2 | user3 | user1 | None | tag3 | None | | + # | 6 | status3 | user2 | user1 | None | tag1 tag2 | epic0 epic2 | None | + # | 7 | status0 | user1 | user2 | None | tag3 | None | | + # | 8 | status3 | user3 | user2 | None | tag1 | epic2 | None | + # | 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], milestone=None) + 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], milestone=None) + 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], milestone=None) + 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], milestone=None) + 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], milestone=None) + 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["userstories"] = [us0, us1, us2, us3, us4, us5, us6, us7, us8, us9] + + return data + + +def test_get_userstories_from_bulk(): + data = "User Story #1\nUser Story #2\n" + userstories = services.get_userstories_from_bulk(data) + + assert len(userstories) == 2 + assert userstories[0].subject == "User Story #1" + assert userstories[1].subject == "User Story #2" + + +def test_create_userstories_in_bulk(): + data = "User Story #1\nUser Story #2\n" + project = f.ProjectFactory.create() + + with mock.patch("taiga.projects.userstories.services.db") as db: + userstories = services.create_userstories_in_bulk(data, project=project) + db.save_in_bulk.assert_called_once_with(userstories, None, None) + + +def test_update_userstories_order_in_bulk(): + project = f.ProjectFactory.create() + us1 = f.UserStoryFactory.create(project=project, backlog_order=1) + us2 = f.UserStoryFactory.create(project=project, backlog_order=2) + data = [{"us_id": us1.id, "order": 2}, {"us_id": us2.id, "order": 1}] + + with mock.patch("taiga.projects.userstories.services.db") as db: + services.update_userstories_order_in_bulk(data, "backlog_order", project) + db.update_attr_in_bulk_for_ids.assert_called_once_with({us2.id: 1, us1.id: 2}, + "backlog_order", + models.UserStory) + + +def test_create_userstory_with_assign_to(client): + user = f.UserFactory.create() + user_watcher = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + f.MembershipFactory.create(project=project, user=user_watcher, + is_admin=True) + url = reverse("userstories-list") + + data = {"subject": "Test user story", "project": project.id, + "assigned_to": user.id} + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert response.data["assigned_to"] == user.id + + +def test_create_userstory_with_assigned_users(client): + user = f.UserFactory.create() + user_watcher = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + f.MembershipFactory.create(project=project, user=user_watcher, + is_admin=True) + url = reverse("userstories-list") + + data = {"subject": "Test user story", "project": project.id, + "assigned_users": [user.id, user_watcher.id]} + client.login(user) + json_data = json.dumps(data) + + response = client.json.post(url, json_data) + + assert response.status_code == 201 + assert response.data["assigned_users"] == set([user.id, user_watcher.id]) + + +def test_create_userstory_with_watchers(client): + user = f.UserFactory.create() + user_watcher = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + f.MembershipFactory.create(project=project, user=user_watcher, is_admin=True) + url = reverse("userstories-list") + + data = {"subject": "Test user story", "project": project.id, "watchers": [user_watcher.id]} + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert response.data["watchers"] == [] + + +def test_create_userstory_without_status(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + status = f.UserStoryStatusFactory.create(project=project) + project.default_us_status = status + project.save() + + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("userstories-list") + + data = {"subject": "Test user story", "project": project.id} + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data['status'] == project.default_us_status.id + + +def test_create_userstory_without_default_values(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user, default_us_status=None) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("userstories-list") + + data = {"subject": "Test user story", "project": project.id} + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert response.data['status'] is None + + +def test_api_delete_userstory(client): + us = f.UserStoryFactory.create() + f.MembershipFactory.create(project=us.project, user=us.owner, is_admin=True) + url = reverse("userstories-detail", kwargs={"pk": us.pk}) + + client.login(us.owner) + response = client.delete(url) + + assert response.status_code == 204 + + +def test_api_filter_by_subject_or_ref(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + f.UserStoryFactory.create(project=project) + f.UserStoryFactory.create(project=project, subject="some random subject") + url = reverse("userstories-list") + "?q=some subject" + + client.login(project.owner) + response = client.get(url) + number_of_stories = len(response.data) + + assert response.status_code == 200 + assert number_of_stories == 1, number_of_stories + + +def test_api_create_in_bulk_with_status(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("userstories-bulk-create") + data = { + "bulk_stories": "Story #1\nStory #2", + "project_id": project.id, + "status_id": project.default_us_status.id + } + + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200, response.data + assert response.data[0]["status"] == project.default_us_status.id + + +def test_api_create_in_bulk_with_invalid_status(client): + project = f.create_project() + status = f.UserStoryStatusFactory.create() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("userstories-bulk-create") + data = { + "bulk_stories": "Story #1\nStory #2", + "project_id": project.id, + "status_id": status.id + } + + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400, response.data + assert "status_id" in response.data + + +def test_api_create_in_bulk_with_swimlane(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + swimlane = f.create_swimlane(project=project) + url = reverse("userstories-bulk-create") + data = { + "bulk_stories": "Story #1\nStory #2", + "project_id": project.id, + "swimlane_id": project.default_swimlane_id, + } + + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200, response.data + assert response.data[1]["swimlane"] == project.default_swimlane_id + + +def test_api_create_in_bulk_with_invalid_swimlane(client): + project = f.create_project() + swimlane = f.create_swimlane() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("userstories-bulk-create") + data = { + "bulk_stories": "Story #1\nStory #2", + "project_id": project.id, + "swimlane_id": swimlane.id, + } + + client.login(project.owner) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400, response.data + assert "swimlane_id" in response.data + + +def test_api_create_in_bulk_with_swimlane_unassigned(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("userstories-bulk-create") + + client.login(project.owner) + + data = { + "bulk_stories": "Story #1\nStory #2", + "project_id": project.id, + "swimlane_id": None, + } + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert response.data[1]["swimlane"] == None + + +def test_api_update_milestone_in_bulk(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + milestone = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project) + t1 = f.create_task(user_story=us1, project=project) + t2 = f.create_task(user_story=us1, project=project) + us2 = f.create_userstory(project=project) + t3 = f.create_task(user_story=us2, project=project) + us3 = f.create_userstory(project=project, milestone=milestone, sprint_order=1) + us4 = f.create_userstory(project=project, milestone=milestone, sprint_order=2) + + url = reverse("userstories-bulk-update-milestone") + data = { + "project_id": project.id, + "milestone_id": milestone.id, + "bulk_stories": [{"us_id": us1.id, "order": 2}, + {"us_id": us2.id, "order": 3}] + } + + client.login(project.owner) + + assert project.milestones.get(id=milestone.id).user_stories.count() == 2 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 204, response.data + assert project.milestones.get(id=milestone.id).user_stories.count() == 4 + + uss_list = list(project.milestones.get(id=milestone.id).user_stories.order_by("sprint_order") + .values_list("id", "sprint_order")) + assert uss_list == [(us3.id, 1), (us1.id, 2), (us2.id,3), (us4.id,4)] + + tasks_list = list(project.milestones.get(id=milestone.id).tasks.order_by("id") + .values_list("id", flat=True)) + assert tasks_list == [t1.id, t2.id, t3.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) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + m2 = f.MilestoneFactory.create() + + url = reverse("userstories-bulk-update-milestone") + data = { + "project_id": project.id, + "milestone_id": m2.id, + "bulk_stories": [{"us_id": us1.id, "order": 1}, + {"us_id": us2.id, "order": 2}] + } + + 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_api_update_milestone_in_bulk_invalid_userstories(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory() + milestone = f.MilestoneFactory.create(project=project) + + url = reverse("userstories-bulk-update-milestone") + data = { + "project_id": project.id, + "milestone_id": milestone.id, + "bulk_stories": [{"us_id": us1.id, "order": 1}, + {"us_id": us2.id, "order": 2}] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "bulk_stories" in response.data + + +def test_update_userstory_points(client): + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user1) + + role1 = f.RoleFactory.create(project=project) + role2 = f.RoleFactory.create(project=project) + + f.MembershipFactory.create(project=project, user=user1, role=role1, is_admin=True) + f.MembershipFactory.create(project=project, user=user2, role=role2) + + points1 = f.PointsFactory.create(project=project, value=None) + points2 = f.PointsFactory.create(project=project, value=1) + points3 = f.PointsFactory.create(project=project, value=2) + + us = f.create_userstory(project=project, owner=user1, status__project=project, + milestone__project=project) + + url = reverse("userstories-detail", args=[us.pk]) + + client.login(user1) + + # invalid role + data = { + "version": us.version, + "points": { + str(role1.pk): points1.pk, + str(role2.pk): points2.pk, + "222222": points3.pk + } + } + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + # invalid point + data = { + "version": us.version, + "points": { + str(role1.pk): 999999, + str(role2.pk): points2.pk + } + } + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + # Api should save successful + data = { + "version": us.version, + "points": { + str(role1.pk): points3.pk, + str(role2.pk): points2.pk + } + } + + response = client.json.patch(url, json.dumps(data)) + assert response.data["points"][str(role1.pk)] == points3.pk + + +def test_update_userstory_rolepoints_on_add_new_role(client): + # This test is explicitly without assertions. It simple should + # works without raising any exception. + + user1 = f.UserFactory.create() + user2 = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user1) + + role1 = f.RoleFactory.create(project=project) + + f.MembershipFactory.create(project=project, user=user1, role=role1) + + f.PointsFactory.create(project=project, value=2) + + us = f.UserStoryFactory.create(project=project, owner=user1) + # url = reverse("userstories-detail", args=[us.pk]) + # client.login(user1) + + role2 = f.RoleFactory.create(project=project, computable=True) + f.MembershipFactory.create(project=project, user=user2, role=role2) + us.save() + + +def test_archived_filter(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + f.UserStoryFactory.create(project=project) + archived_status = f.UserStoryStatusFactory.create(is_archived=True) + f.UserStoryFactory.create(status=archived_status, project=project) + + client.login(user) + + url = reverse("userstories-list") + + data = {} + response = client.get(url, data) + assert len(response.data) == 2 + + data = {"status__is_archived": 0} + response = client.get(url, data) + assert len(response.data) == 1 + + data = {"status__is_archived": 1} + response = client.get(url, data) + assert len(response.data) == 1 + + +def test_filter_by_multiple_status(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + f.UserStoryFactory.create(project=project) + us1 = f.UserStoryFactory.create(project=project) + us2 = f.UserStoryFactory.create(project=project) + + client.login(user) + + url = "{}?status={},{}".format(reverse("userstories-list"), us1.status.id, us2.status.id) + + data = {} + response = client.get(url, data) + assert len(response.data) == 2 + + +def test_get_total_points(client): + project = f.ProjectFactory.create() + + role1 = f.RoleFactory.create(project=project) + role2 = f.RoleFactory.create(project=project) + + points1 = f.PointsFactory.create(project=project, value=None) + points2 = f.PointsFactory.create(project=project, value=1) + points3 = f.PointsFactory.create(project=project, value=2) + + us_with_points = f.UserStoryFactory.create(project=project) + us_with_points.role_points.all().delete() + f.RolePointsFactory.create(user_story=us_with_points, role=role1, points=points2) + f.RolePointsFactory.create(user_story=us_with_points, role=role2, points=points3) + + assert us_with_points.get_total_points() == 3.0 + + us_without_points = f.UserStoryFactory.create(project=project) + us_without_points.role_points.all().delete() + f.RolePointsFactory.create(user_story=us_without_points, role=role1, points=points1) + f.RolePointsFactory.create(user_story=us_without_points, role=role2, points=points1) + + assert us_without_points.get_total_points() is None + + us_mixed = f.UserStoryFactory.create(project=project) + us_mixed.role_points.all().delete() + f.RolePointsFactory.create(user_story=us_mixed, role=role1, points=points1) + f.RolePointsFactory.create(user_story=us_mixed, role=role2, points=points2) + + assert us_mixed.get_total_points() == 1.0 + + +def test_api_filter_by_created_date(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_userstory = f.create_userstory(owner=user, created_date=one_day_ago) + userstory = f.create_userstory(owner=user, subject="test") + + url = reverse("userstories-list") + "?created_date=%s" % ( + quote(userstory.created_date.isoformat()) + ) + + client.login(userstory.owner) + response = client.get(url) + number_of_userstories = len(response.data) + + assert response.status_code == 200 + assert number_of_userstories == 1 + assert response.data[0]["subject"] == userstory.subject + + +def test_api_filter_by_created_date__lt(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_userstory = f.create_userstory( + owner=user, created_date=one_day_ago, subject="old test" + ) + userstory = f.create_userstory(owner=user) + + url = reverse("userstories-list") + "?created_date__lt=%s" % ( + quote(userstory.created_date.isoformat()) + ) + + client.login(userstory.owner) + response = client.get(url) + number_of_userstories = len(response.data) + + assert response.status_code == 200 + assert response.data[0]["subject"] == old_userstory.subject + + +def test_api_filter_by_created_date__lte(client): + user = f.UserFactory(is_superuser=True) + one_day_ago = timezone.now() - timedelta(days=1) + + old_userstory = f.create_userstory(owner=user, created_date=one_day_ago) + userstory = f.create_userstory(owner=user) + + url = reverse("userstories-list") + "?created_date__lte=%s" % ( + quote(userstory.created_date.isoformat()) + ) + + client.login(userstory.owner) + response = client.get(url) + number_of_userstories = len(response.data) + + assert response.status_code == 200 + assert number_of_userstories == 2 + + +def test_api_filter_by_modified_date__gte(client): + user = f.UserFactory(is_superuser=True) + + older_userstory = f.create_userstory(owner=user) + userstory = f.create_userstory(owner=user, subject="test") + # we have to refresh as it slightly differs + userstory.refresh_from_db() + + assert older_userstory.modified_date < userstory.modified_date + + url = reverse("userstories-list") + "?modified_date__gte=%s" % ( + quote(userstory.modified_date.isoformat()) + ) + + client.login(userstory.owner) + response = client.get(url) + number_of_userstories = len(response.data) + + assert response.status_code == 200 + assert number_of_userstories == 1 + assert response.data[0]["subject"] == userstory.subject + + +def test_api_filter_by_finish_date(client): + user = f.UserFactory(is_superuser=True) + one_day_later = timezone.now() + timedelta(days=1) + + userstory = f.create_userstory(owner=user) + userstory_to_finish = f.create_userstory( + owner=user, finish_date=one_day_later, subject="test" + ) + + assert userstory_to_finish.finish_date + + url = reverse("userstories-list") + "?finish_date__gte=%s" % ( + quote(userstory_to_finish.finish_date.isoformat()) + ) + client.login(userstory.owner) + response = client.get(url) + number_of_userstories = len(response.data) + + assert response.status_code == 200 + assert number_of_userstories == 1 + assert response.data[0]["subject"] == userstory_to_finish.subject + + +def test_api_filter_by_assigned_users(client): + user = f.UserFactory(is_superuser=True) + user2 = f.UserFactory(is_superuser=True) + project = f.ProjectFactory.create(owner=user) + + f.MembershipFactory.create(user=user, project=project) + + f.create_userstory(owner=user, subject="test 2 users", assigned_to=user, + assigned_users=[user.id, user2.id], project=project) + f.create_userstory( + owner=user, subject="test 1 user", assigned_to=user, + assigned_users=[user.id], project=project + ) + + url = reverse("userstories-list") + "?assigned_users=%s" % (user.id) + + client.login(user) + response = client.get(url) + number_of_userstories = len(response.data) + + assert response.status_code == 200 + assert number_of_userstories == 2 + + +def test_regresion_api_filter_by_assigned_users_for_no_members(client): + user = f.UserFactory() + user2 = f.UserFactory() + user3 = f.UserFactory() + project = f.create_project(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)) + ["comment_us"], + owner=user) + + f.MembershipFactory(project=project, + user=user, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.MembershipFactory(project=project, + user=user2, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + f.create_userstory(project=project, subject="test 1", assigned_to=user, + assigned_users=[user.id, user2.id]) + f.create_userstory(project=project, subject="test 2", assigned_to=user, + assigned_users=[user.id]) + f.create_userstory(project=project, subject="test 3") + + url = reverse("userstories-list") + "?assigned_users=%s" % (user.id) + + # Project owner + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 2 + + # Member + client.login(user2) + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 2 + + # No member + client.login(user3) + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 2 + + # Anonymous user + client.logout() + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 2 + + +def test_api_filter_by_role(client): + project = f.ProjectFactory.create() + role1 = f.RoleFactory.create() + + user = f.UserFactory(is_superuser=True) + user2 = f.UserFactory(is_superuser=True) + f.MembershipFactory.create(user=user2, project=project, role=role1) + + userstory = f.create_userstory(owner=user, subject="test 2 users", + assigned_to=user, + assigned_users=[user.id, user2.id], + project=project) + f.create_userstory( + owner=user, subject="test 1 user", assigned_to=user, + assigned_users=[user.id], + project=project + ) + + url = reverse("userstories-list") + "?role=%s" % (role1.id) + + client.login(userstory.owner) + response = client.get(url) + number_of_userstories = len(response.data) + + assert response.status_code == 200 + assert number_of_userstories == 1 + + +@pytest.mark.parametrize("field_name", ["estimated_start", "estimated_finish"]) +def test_api_filter_by_milestone__estimated_start_and_end(client, field_name): + user = f.UserFactory(is_superuser=True) + userstory = f.create_userstory(owner=user) + + assert userstory.milestone + assert hasattr(userstory.milestone, field_name) + date = getattr(userstory.milestone, field_name) + before = (date - timedelta(days=1)).isoformat() + after = (date + timedelta(days=1)).isoformat() + + client.login(userstory.owner) + + full_field_name = "milestone__" + field_name + expections = { + full_field_name + "__gte=" + quote(before): 1, + full_field_name + "__gte=" + quote(after): 0, + full_field_name + "__lte=" + quote(before): 0, + full_field_name + "__lte=" + quote(after): 1 + } + + for param, expection in expections.items(): + url = reverse("userstories-list") + "?" + param + response = client.get(url) + number_of_userstories = len(response.data) + + assert response.status_code == 200 + assert number_of_userstories == expection, param + if number_of_userstories > 0: + assert response.data[0]["subject"] == userstory.subject + + +def test_api_filters_data(client): + 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) + + # Check filter fields + response = client.get(url) + assert response.status_code == 200 + + owners = next(filter(lambda i: i['id'] == user1.id, response.data["owners"])) + assert len(owners) == 6 + assert 'id' in owners + assert 'count' in owners + assert 'full_name' in owners + assert 'photo' in owners + assert 'big_photo' in owners + assert 'gravatar_id' in owners + + assigned_users = next(filter(lambda i: i['id'] == user1.id, response.data["assigned_users"])) + assert len(assigned_users) == 6 + assert 'id' in assigned_users + assert 'count' in assigned_users + assert 'full_name' in assigned_users + assert 'photo' in assigned_users + assert 'big_photo' in assigned_users + assert 'gravatar_id' in assigned_users + + # No filter + response = client.get(url) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 3 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 4 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_users"]))["count"] == 5 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_users"]))["count"] == 2 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 1 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 5 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 4 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 4 + + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 5 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 2 + + # Filter ((status0 or status3) + response = client.get(url + "&status={},{}".format(status3.id, status0.id)) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 4 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3 + + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 2 + + # Filter ((tag1 and tag2) and (user1 or user2)) + response = client.get(url + "&tags={},{}&owner={},{}".format(tag1, tag2, user1.id, user2.id)) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 2 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 3 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 1 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 3 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3 + + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 1 + + # Filter (epic0 epic2) + response = client.get(url + "&epic={},{}".format(epic0.id, epic2.id)) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 3 + + assert next(filter(lambda i: i['name'] == tag0, response.data["tags"]))["count"] == 0 + assert next(filter(lambda i: i['name'] == tag1, response.data["tags"]))["count"] == 4 + assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 2 + assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] is None, response.data["epics"]))["count"] == 5 + assert next(filter(lambda i: i['id'] == epic0.id, response.data["epics"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == epic1.id, response.data["epics"]))["count"] == 1 + 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 + assert "taiga-info-backlog-total-userstories" in response["access-control-expose-headers"] + assert response.has_header("Taiga-Info-Backlog-Total-Userstories") == False + + # 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 + assert "taiga-info-backlog-total-userstories" in response["access-control-expose-headers"] + assert response.has_header("Taiga-Info-Backlog-Total-Userstories") == False + + +@pytest.mark.parametrize("filter_name,collection,expected,exclude_expected,backlog_total_uss,is_text", [ + ('status', 'statuses', 1, 4, 5, False), + ('tags', 'tags', 0, 5, 5, True), + ('owner', 'users', 1, 4, 5, False), + ('role', 'roles', 2, 3, 5, False), + ('assigned_users', 'users', 2, 3, 5, False), +]) +def test_api_filters_for_backlog(client, filter_name, collection, expected, exclude_expected, backlog_total_uss, 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={}&milestone=null&{}={}".format(reverse('userstories-list'), project.id, filter_name, param) + response = client.get(url) + assert response.status_code == 200 + assert len(response.data) == expected + assert "taiga-info-backlog-total-userstories" in response["access-control-expose-headers"] + assert response.has_header("Taiga-Info-Backlog-Total-Userstories") == True + assert response["taiga-info-backlog-total-userstories"] == f"{backlog_total_uss}" + + # exclude test + url = "{}?project={}&milestone=null&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 + assert "taiga-info-backlog-total-userstories" in response["access-control-expose-headers"] + assert response.has_header("Taiga-Info-Backlog-Total-Userstories") == True + assert response["taiga-info-backlog-total-userstories"] == f"{backlog_total_uss}" + + +def test_api_filters_tags_or_operator(client): + data = create_uss_fixtures() + project = data["project"] + client.login(data["users"][0]) + tags = data["tags"] + + url = "{}?project={}&tags={},{}".format(reverse('userstories-list'), project.id, tags[0], + tags[2]) + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 5 + + +def test_api_filters_data_with_assigned_users(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) + + # ----------------------------------------------------------- + # | US | Status | Owner | Assigned To | Assigned Users | + # |-------#---------#--------#-------------#----------------- + # | 0 | status3 | user2 | user2 | user2, user3 | + # | 1 | status3 | user1 | None | None | + # | 2 | status1 | user3 | None | None | + # | 3 | status0 | user2 | None | None | + # | 4 | status0 | user1 | user1 | user1 | + # ----------------------------------------------------------- + + us0 = f.UserStoryFactory.create(project=project, owner=user2, + assigned_to=user2, + assigned_users=[user2, user3], + status=status3) + f.RelatedUserStory.create(user_story=us0) + us1 = f.UserStoryFactory.create(project=project, owner=user1, + assigned_to=None, + status=status3, ) + us2 = f.UserStoryFactory.create(project=project, owner=user3, + assigned_to=None, + status=status1) + f.RelatedUserStory.create(user_story=us2) + us3 = f.UserStoryFactory.create(project=project, owner=user2, + assigned_to=None, + status=status0) + us4 = f.UserStoryFactory.create(project=project, owner=user1, + assigned_to=user1, + assigned_users=[user1], + status=status0) + + url = reverse("userstories-filters-data") + "?project={}".format(project.id) + + client.login(user1) + + # Check filter fields + response = client.get(url) + assert response.status_code == 200 + + owners = next(filter(lambda i: i['id'] == user1.id, response.data["owners"])) + assert len(owners) == 6 + assert 'id' in owners + assert 'count' in owners + assert 'full_name' in owners + assert 'photo' in owners + assert 'big_photo' in owners + assert 'gravatar_id' in owners + + assigned_users = next(filter(lambda i: i['id'] == user1.id, response.data["assigned_users"])) + assert len(assigned_users) == 6 + assert 'id' in assigned_users + assert 'count' in assigned_users + assert 'full_name' in assigned_users + assert 'photo' in assigned_users + assert 'big_photo' in assigned_users + assert 'gravatar_id' in assigned_users + + # No filter + response = client.get(url) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 3 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 + + assert next(filter(lambda i: i['id'] == status0.id, response.data["statuses"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == status1.id, response.data["statuses"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == status2.id, response.data["statuses"]))["count"] == 0 + assert next(filter(lambda i: i['id'] == status3.id, response.data["statuses"]))["count"] == 2 + + assert next(filter(lambda i: i['id'] == user1.id, + response.data["assigned_users"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user2.id, + response.data["assigned_users"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user3.id, + response.data["assigned_users"]))["count"] == 1 + + +def test_api_filters_data_roles_with_assigned_users(client): + project = f.ProjectFactory.create() + + role1 = f.RoleFactory.create(project=project) + role2 = f.RoleFactory.create(project=project) + + user1 = f.UserFactory.create(is_superuser=True) + f.MembershipFactory.create(user=user1, project=project, role=role1) + user2 = f.UserFactory.create(is_superuser=True) + f.MembershipFactory.create(user=user2, project=project, role=role2) + user3 = f.UserFactory.create(is_superuser=True) + f.MembershipFactory.create(user=user3, project=project, role=role1) + + + # ---------------------------------------------------------------- + # | US | Owner | Assigned To | Assigned Users | Role | + # |-------#--------#-------------#----------------#--------------- + # | 0 | user2 | user2 | user2, user3 | role2, role1 | + # | 1 | user1 | None | None | None | + # | 2 | user1 | user1 | user1 | role1 | + # ---------------------------------------------------------------- + + us0 = f.UserStoryFactory.create(project=project, owner=user2, status__project=project, + assigned_to=user2, + assigned_users=[user2, user3],) + f.RelatedUserStory.create(user_story=us0) + us1 = f.UserStoryFactory.create(project=project, owner=user1, status__project=project, + assigned_to=None) + us2 = f.UserStoryFactory.create(project=project, owner=user1, status__project=project, + assigned_to=user1, + assigned_users=[user1],) + + url = reverse("userstories-filters-data") + "?project={}".format(project.id) + + client.login(user1) + + # No filter + response = client.get(url) + assert response.status_code == 200 + + assert next(filter(lambda i: i['id'] == user1.id, response.data["owners"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] == user1.id, + response.data["assigned_users"]))["count"] == 1 + assert next(filter(lambda i: i['id'] == user2.id, + response.data["assigned_users"]))["count"] == 1 + + assert next(filter(lambda i: i['id'] == role1.id, + response.data["roles"]))["count"] == 2 + assert next(filter(lambda i: i['id'] == role2.id, + response.data["roles"]))["count"] == 1 + + +def test_get_invalid_csv(client): + url = reverse("userstories-csv") + project = f.ProjectFactory.create() + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 404 + + response = client.get("{}?uuid={}".format(url, "not-valid-uuid")) + assert response.status_code == 404 + + +def test_get_valid_csv(client): + url = reverse("userstories-csv") + project = f.ProjectFactory.create(userstories_csv_uuid=uuid.uuid4().hex) + + response = client.get( + "{}?uuid={}".format(url, project.userstories_csv_uuid)) + assert response.status_code == 200 + + +def test_custom_fields_csv_generation(): + project = f.ProjectFactory.create(userstories_csv_uuid=uuid.uuid4().hex) + attr = f.UserStoryCustomAttributeFactory.create(project=project, + name="attr1", + description="desc") + us = f.UserStoryFactory.create(project=project) + attr_values = us.custom_attributes_values + attr_values.attributes_values = {str(attr.id): "val1"} + attr_values.save() + queryset = project.user_stories.all() + data = services.userstories_to_csv(project, queryset) + data.seek(0) + reader = csv.reader(data) + row = next(reader) + + assert row.pop() == attr.name + row = next(reader) + assert row.pop() == "val1" + + +def test_update_userstory_respecting_watchers(client): + watching_user = f.create_user() + project = f.ProjectFactory.create() + us = f.UserStoryFactory.create(project=project, status__project=project, + milestone__project=project) + us.add_watcher(watching_user) + f.MembershipFactory.create(project=us.project, user=us.owner, + is_admin=True) + f.MembershipFactory.create(project=us.project, user=watching_user) + + client.login(user=us.owner) + url = reverse("userstories-detail", kwargs={"pk": us.pk}) + data = {"subject": "Updating test", "version": 1} + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["subject"] == "Updating test" + assert response.data["watchers"] == [watching_user.id] + + +def test_update_userstory_update_watchers(client): + watching_user = f.create_user() + project = f.ProjectFactory.create() + us = f.UserStoryFactory.create(project=project, status__project=project, + milestone__project=project) + f.MembershipFactory.create(project=us.project, user=us.owner, + is_admin=True) + f.MembershipFactory.create(project=us.project, user=watching_user) + + client.login(user=us.owner) + url = reverse("userstories-detail", kwargs={"pk": us.pk}) + data = {"watchers": [watching_user.id], "version": 1} + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["watchers"] == [watching_user.id] + watcher_ids = list(us.get_watchers().values_list("id", flat=True)) + assert watcher_ids == [watching_user.id] + + +def test_update_userstory_remove_watchers(client): + watching_user = f.create_user() + project = f.ProjectFactory.create() + us = f.UserStoryFactory.create(project=project, status__project=project, + milestone__project=project) + us.add_watcher(watching_user) + f.MembershipFactory.create(project=us.project, user=us.owner, + is_admin=True) + f.MembershipFactory.create(project=us.project, user=watching_user) + + client.login(user=us.owner) + url = reverse("userstories-detail", kwargs={"pk": us.pk}) + data = {"watchers": [], "version": 1} + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["watchers"] == [] + watcher_ids = list(us.get_watchers().values_list("id", flat=True)) + assert watcher_ids == [] + + +def test_update_userstory_update_tribe_gig(client): + project = f.ProjectFactory.create() + us = f.UserStoryFactory.create(project=project, status__project=project, + milestone__project=project) + f.MembershipFactory.create(project=us.project, user=us.owner, + is_admin=True) + + url = reverse("userstories-detail", kwargs={"pk": us.pk}) + data = { + "tribe_gig": { + "id": 2, + "title": "This is a gig test title" + }, + "version": 1 + } + + client.login(user=us.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200 + assert response.data["tribe_gig"] == data["tribe_gig"] + + +def test_get_user_stories_including_tasks(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + user_story = f.UserStoryFactory.create(project=project) + f.TaskFactory.create(user_story=user_story) + url = reverse("userstories-list") + + client.login(project.owner) + + response = client.get(url) + assert response.status_code == 200 + assert response.data[0].get("tasks") == [] + + url = reverse("userstories-list") + "?include_tasks=1" + response = client.get(url) + assert response.status_code == 200 + assert len(response.data[0].get("tasks")) == 1 + + +def test_get_user_stories_including_attachments(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + user_story = f.UserStoryFactory.create(project=project) + f.UserStoryAttachmentFactory(project=project, content_object=user_story) + url = reverse("userstories-list") + + client.login(project.owner) + + response = client.get(url) + assert response.status_code == 200 + assert response.data[0].get("attachments") == [] + + url = reverse("userstories-list") + "?include_attachments=1" + response = client.get(url) + assert response.status_code == 200 + assert len(response.data[0].get("attachments")) == 1 + + +def test_api_validator_assigned_to_when_update_userstories(client): + project = f.create_project(anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + project_member_owner = f.MembershipFactory.create(project=project, + user=project.owner, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_member = f.MembershipFactory.create(project=project, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_no_member = f.MembershipFactory.create(is_admin=True) + userstory = f.create_userstory(project=project, owner=project.owner, status=project.us_statuses.all()[0]) + + url = reverse('userstories-detail', kwargs={"pk": userstory.pk}) + + # assign + data = { + "assigned_to": project_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == project_member.user.id + + # unassign + data = { + "assigned_to": None, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == None + + # assign to invalid user + data = { + "assigned_to": project_no_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + + +def test_api_validator_assigned_to_when_create_userstories(client): + project = f.create_project(anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + project_member_owner = f.MembershipFactory.create(project=project, + user=project.owner, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_member = f.MembershipFactory.create(project=project, + is_admin=True, + role__project=project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project_no_member = f.MembershipFactory.create(is_admin=True) + + url = reverse('userstories-list') + + # assign + data = { + "subject": "test", + "project": project.id, + "assigned_to": project_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == project_member.user.id + + # unassign + data = { + "subject": "test", + "project": project.id, + "assigned_to": None, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + assert "assigned_to" in response.data + assert response.data["assigned_to"] == None + + # assign to invalid user + data = { + "subject": "test", + "project": project.id, + "assigned_to": project_no_member.user.id, + } + + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + + +def test_update_userstory_backlog_order(client): + user1 = f.UserFactory.create() + project = f.create_project(owner=user1) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory(project=project, owner=user1, status__project=project, milestone=None, backlog_order=0) + us2 = f.create_userstory(project=project, owner=user1, status__project=project, milestone=None, backlog_order=1) + us3 = f.create_userstory(project=project, owner=user1, status__project=project, milestone=None, backlog_order=2) + us4 = f.create_userstory(project=project, owner=user1, status__project=project, milestone=None, backlog_order=3) + url = reverse("userstories-detail", args=[us4.pk]) + + data = { + "version": us1.version, + "backlog_order": 1 + } + + client.login(project.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + assert 1 == response.data['backlog_order'] + + url = reverse("userstories-list") + "?milestone=null&project={}".format(project.id) + client.login(project.owner) + response = client.get(url) + user_stories = response.data + number_of_stories = len(user_stories) + + assert response.status_code == 200 + assert number_of_stories == 4, number_of_stories + assert 0 == user_stories[0]["backlog_order"] + assert us1.id == user_stories[0]["id"] + assert us4.id == user_stories[1]["id"] + assert 1 == user_stories[1]["backlog_order"] + assert us2.id == user_stories[2]["id"] + assert 2 == user_stories[2]["backlog_order"] + assert us3.id == user_stories[3]["id"] + assert 3 == user_stories[3]["backlog_order"] + + + +def test_api_update_change_kanban_order_if_project_change(client): + user1 = f.UserFactory.create() + project1 = f.create_project(owner=user1) + f.MembershipFactory.create(project=project1, user=project1.owner, is_admin=True) + project2 = f.create_project(owner=user1) + f.MembershipFactory.create(project=project2, user=project2.owner, is_admin=True) + us = f.create_userstory(project=project1, owner=user1, status=project1.default_us_status) + + url = reverse("userstories-detail", args=[us.pk]) + data = { + "version": us.version, + "project": project2.id, + "status": project2.default_us_status.id, + "milestone": None, + } + + client.login(project1.owner) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + + assert us.kanban_order < response.data["kanban_order"] + assert project2.id == response.data["project"] + + +def test_api_update_change_kanban_order_if_status_change(client): + user1 = f.UserFactory.create() + project = f.create_project(owner=user1) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status1 = f.UserStoryStatusFactory(project=project) + status2 = f.UserStoryStatusFactory(project=project) + us = f.create_userstory(project=project, owner=user1, + status=status1, swimlane=project.default_swimlane) + + url = reverse("userstories-detail", args=[us.pk]) + data = { + "version": us.version, + "status": status2.id + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + + assert us.kanban_order < response.data["kanban_order"] + assert status2.id == response.data["status"] + + +def test_api_update_change_kanban_order_if_swimlane_change(client): + user1 = f.UserFactory.create() + project = f.create_project(owner=user1) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + swimlane1 = f.SwimlaneFactory(project=project) + swimlane2 = f.SwimlaneFactory(project=project) + us = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=swimlane1) + + url = reverse("userstories-detail", args=[us.pk]) + data = { + "version": us.version, + "swimlane": swimlane2.id + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + + assert us.kanban_order < response.data["kanban_order"] + assert swimlane2.id == response.data["swimlane"] + + +def test_api_headers_userstories_without_swimlane_false(client): + user1 = f.UserFactory.create() + project = f.create_project(owner=user1) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + swimlane1 = f.SwimlaneFactory(project=project) + us1 = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=swimlane1) + us2 = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=swimlane1) + us3 = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=swimlane1) + + url = f"{reverse('userstories-list')}?project={project.id}" + + client.login(project.owner) + response = client.json.get(url) + assert response.status_code == 200, response.data + assert "taiga-info-userstories-without-swimlane" in response["access-control-expose-headers"] + assert response.has_header("Taiga-Info-Userstories-Without-Swimlane") == True + assert response["taiga-info-userstories-without-swimlane"] == "false" + + +def test_api_headers_userstories_without_swimlane_true(client): + user1 = f.UserFactory.create() + project = f.create_project(owner=user1) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + swimlane1 = f.SwimlaneFactory(project=project) + us1 = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=swimlane1) + us2 = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=None) + us3 = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=swimlane1) + + url = f"{reverse('userstories-list')}?project={project.id}" + + client.login(project.owner) + response = client.json.get(url) + assert response.status_code == 200, response.data + assert "taiga-info-userstories-without-swimlane" in response["access-control-expose-headers"] + assert response.has_header("Taiga-Info-Userstories-Without-Swimlane") == True + assert response["taiga-info-userstories-without-swimlane"] == "true" + + +def test_api_headers_userstories_without_swimlane_not_send(client): + user1 = f.UserFactory.create() + project = f.create_project(owner=user1) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + swimlane1 = f.SwimlaneFactory(project=project) + us1 = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=swimlane1) + us2 = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=None) + us3 = f.create_userstory(project=project, owner=user1, + status=project.default_us_status, swimlane=swimlane1) + + url = reverse('userstories-list') + + client.login(project.owner) + response = client.json.get(url) + assert response.status_code == 200, response.data + assert "taiga-info-userstories-without-swimlane" in response["access-control-expose-headers"] + assert response.has_header("Taiga-Info-Userstories-Without-Swimlane") == False + + +def test_api_list_userstory_using_onlyref_serializer(client): + project = f.create_project(owner=f.UserFactory.create()) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + + url = f"{reverse('userstories-list')}?only_ref=true" + + client.login(project.owner) + response = client.json.get(url) + assert response.status_code == 200, response.data + assert set(response.data[0].keys()) == set(["id", "ref"]) + + +def test_bug_regresion_api_by_ref_userstory_using_onlyref_serializer(client): + # Prevent error triying to get detail of userstory with only_ref=true + # + # File ".../taiga-back/taiga/projects/userstories/serializers.py", line 124, in get_points + # assert hasattr(obj, "role_points_attr"), "instance must have a role_points_attr attribute" + # AssertionError: instance must have a role_points_attr attribute + + project = f.create_project(owner=f.UserFactory.create()) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory(project=project) + + url = f"{reverse('userstories-by-ref')}?include_attachments=true&include_tasks=true&only_ref=true&page=40&project={us1.project_id}&ref={us1.ref}" + + client.login(project.owner) + response = client.json.get(url) + assert response.status_code == 200, response.data + assert set(response.data.keys()) != set(["id", "ref"]) + + url = f"{reverse('userstories-detail', args=[us1.id])}?include_attachments=true&include_tasks=true&only_ref=true" + + client.login(project.owner) + response = client.json.get(url) + assert response.status_code == 200, response.data + assert set(response.data.keys()) != set(["id", "ref"]) diff --git a/tests/integration/test_userstories_tags.py b/tests/integration/test_userstories_tags.py new file mode 100644 index 000000000..6297c539f --- /dev/null +++ b/tests/integration/test_userstories_tags.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from unittest import mock +from collections import OrderedDict + +from django.urls import reverse + +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +def test_api_user_story_add_new_tags_with_error(client): + project = f.ProjectFactory.create() + user_story = f.create_userstory(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=user_story.owner, is_admin=True) + url = reverse("userstories-detail", kwargs={"pk": user_story.pk}) + data = { + "tags": [], + "version": user_story.version + } + + client.login(user_story.owner) + + data["tags"] = [1] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [["back"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [["back", "#cccc"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + data["tags"] = [[1, "#ccc"]] + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert "tags" in response.data + + +def test_api_user_story_add_new_tags_without_colors(client): + project = f.ProjectFactory.create() + user_story = f.create_userstory(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=user_story.owner, is_admin=True) + url = reverse("userstories-detail", kwargs={"pk": user_story.pk}) + data = { + "tags": [ + ["back", None], + ["front", None], + ["ux", None] + ], + "version": user_story.version + } + + client.login(user_story.owner) + + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200, response.data + + tags_colors = OrderedDict(project.tags_colors) + assert not tags_colors.keys() + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors + + +def test_api_user_story_add_new_tags_with_colors(client): + project = f.ProjectFactory.create() + user_story = f.create_userstory(project=project, status__project=project) + f.MembershipFactory.create(project=project, user=user_story.owner, is_admin=True) + url = reverse("userstories-detail", kwargs={"pk": user_story.pk}) + data = { + "tags": [ + ["back", "#fff8e7"], + ["front", None], + ["ux", "#fabada"] + ], + "version": user_story.version + } + + client.login(user_story.owner) + + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200, response.data + + tags_colors = OrderedDict(project.tags_colors) + assert not tags_colors.keys() + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert "back" in tags_colors and "front" in tags_colors and "ux" in tags_colors + assert tags_colors["back"] == "#fff8e7" + assert tags_colors["ux"] == "#fabada" + + +def test_api_create_new_user_story_with_tags(client): + project = f.ProjectFactory.create(tags_colors=[["front", "#aaaaaa"], ["ux", "#fabada"]]) + status = f.UserStoryStatusFactory.create(project=project) + project.default_userstory_status = status + project.save() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("userstories-list") + + data = { + "subject": "Test user story", + "project": project.id, + "tags": [ + ["back", "#fff8e7"], + ["front", "#bbbbbb"], + ["ux", None] + ] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + + us_tags_colors = OrderedDict(response.data["tags"]) + + assert us_tags_colors["back"] == "#fff8e7" + assert us_tags_colors["front"] == "#aaaaaa" + assert us_tags_colors["ux"] == "#fabada" + + tags_colors = OrderedDict(project.tags_colors) + + project.refresh_from_db() + + tags_colors = OrderedDict(project.tags_colors) + assert tags_colors["back"] == "#fff8e7" + assert tags_colors["ux"] == "#fabada" + assert tags_colors["front"] == "#aaaaaa" + + +def test_api_create_new_user_story_with_tag_capitalized(client): + project = f.ProjectFactory.create() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + url = reverse("userstories-list") + + data = { + "subject": "Test user story", + "project": project.id, + "tags": [ + ["CapTag", "#fabada"] + ] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.data + + project.refresh_from_db() + assert project.tags_colors == [["captag", "#fabada"]] diff --git a/tests/integration/test_userstories_update_backlog_order.py b/tests/integration/test_userstories_update_backlog_order.py new file mode 100644 index 000000000..3857289e2 --- /dev/null +++ b/tests/integration/test_userstories_update_backlog_order.py @@ -0,0 +1,778 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid +import csv +import pytz + +from datetime import datetime, timedelta +from urllib.parse import quote + +from unittest import mock +from django.urls import reverse + +from taiga.base.utils import json +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.history.models import HistoryEntry +from taiga.projects.occ import OCCResourceMixin +from taiga.projects.userstories import services, models + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db(transaction=True) + + +############################## +## Move to the backlog +############################## + +def test_api_update_orders_in_bulk_succeeds_moved_in_the_backlog_to_the_begining(client): + # + # BLG | ML1 | ML2 | | BLG | ML1 | ML2 + # -------|-------|------- | | -------|-------|------- + # us1 | | | MOVE: us2, us3 | us2 | | + # us2 | | | TO: no-milestone | us3 | | + # us3 | | | AFTER: bigining | us1 | | + # | | | | | | + # | | | | | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + ml1 = f.MilestoneFactory.create(project=project) + ml2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project, backlog_order=1, milestone=None) + us2 = f.create_userstory(project=project, backlog_order=2, milestone=None) + us3 = f.create_userstory(project=project, backlog_order=3, milestone=None) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "after_userstory_id": None, + "bulk_userstories": [us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us2.id, + us3.id, + us1.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "milestone", "backlog_order") + .order_by("backlog_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us2.backlog_order == 1 + assert us2.milestone_id == None + assert us3.backlog_order == 2 + assert us3.milestone_id == None + assert us1.backlog_order == 3 + assert us1.milestone_id == None + + +def test_api_update_orders_in_bulk_succeeds_moved_in_the_backlog_to_the_middle(client): + # + # BLG | ML1 | ML2 | | BLG | ML1 | ML2 + # -------|-------|------- | | -------|-------|------- + # us1 | us2 | | MOVE: us1, us3 | us4 | us2 | + # us4 | us3 | | TO: no-milestone | us1 | | + # us5 | | | AFTER: us4 | us3 | | + # | | | | us5 | | + # | | | | | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + ml1 = f.MilestoneFactory.create(project=project) + ml2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project, backlog_order=1, milestone=None) + us4 = f.create_userstory(project=project, backlog_order=2, milestone=None) + us5 = f.create_userstory(project=project, backlog_order=3, milestone=None) + us2 = f.create_userstory(project=project, sprint_order=1, milestone=ml1) + us3 = f.create_userstory(project=project, sprint_order=2, milestone=ml1) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us1.id, + us3.id, + us5.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "milestone", "backlog_order") + .order_by("backlog_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + us4.refresh_from_db() + us5.refresh_from_db() + assert us4.backlog_order == 2 + assert us4.milestone_id == None + assert us1.backlog_order == 3 + assert us1.milestone_id == None + assert us3.backlog_order == 4 + assert us3.milestone_id == None + assert us5.backlog_order == 5 + assert us5.milestone_id == None + assert us2.sprint_order == 1 + assert us2.milestone_id == ml1.id + + +def test_api_update_orders_in_bulk_succeeds_moved_in_the_backlog_to_the_end(client): + # + # BLG | ML1 | ML2 | | BLG | ML1 | ML2 + # -------|-------|------- | | -------|-------|------- + # us1 | us3 | | MOVE: us3 | us1 | | + # us2 | | | TO: no-milestone | us2 | | + # | | | AFTER: us2 | us3 | | + # | | | | | | + # | | | | | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + ml1 = f.MilestoneFactory.create(project=project) + ml2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project, backlog_order=1, milestone=None) + us2 = f.create_userstory(project=project, backlog_order=2, milestone=None) + us3 = f.create_userstory(project=project, sprint_order=1, milestone=ml1) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "after_userstory_id": us2.id, + "bulk_userstories": [us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us3.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "milestone", "backlog_order") + .order_by("backlog_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us1.backlog_order == 1 + assert us1.milestone_id == None + assert us2.backlog_order == 2 + assert us2.milestone_id == None + assert us3.backlog_order == 3 + assert us3.milestone_id == None + + +def test_api_update_orders_in_bulk_succeeds_moved_in_the_backlog_before_one_us(client): + # + # BLG | ML1 | ML2 | | BLG | ML1 | ML2 + # -------|-------|------- | | -------|-------|------- + # us1 | us3 | | MOVE: us3 | us1 | | + # us2 | | | TO: no-milestone | us3 | | + # | | | BEFORE: us2 | us2 | | + # | | | | | | + # | | | | | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + ml1 = f.MilestoneFactory.create(project=project) + ml2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project, backlog_order=1, milestone=None) + us2 = f.create_userstory(project=project, backlog_order=2, milestone=None) + us3 = f.create_userstory(project=project, sprint_order=1, milestone=ml1) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "before_userstory_id": us2.id, + "bulk_userstories": [us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us2.id, + us3.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "milestone", "backlog_order") + .order_by("backlog_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us1.backlog_order == 1 + assert us1.milestone_id == None + assert us3.backlog_order == 2 + assert us3.milestone_id == None + assert us2.backlog_order == 3 + assert us2.milestone_id == None + + +############################## +## Move to sprint +############################## + +def test_api_update_orders_in_bulk_succeeds_moved_to_a_milestone_to_the_begining(client): + # + # BLG | ML1 | ML2 | | BLG | ML1 | ML2 + # -------|-------|------- | | -------|-------|------- + # | us1 | | MOVE: us2, us3 | | us2 | + # | | us2 | TO: milestone-1 | | us3 | + # | | us3 | AFTER: bigining | | us1 | + # | | | | | | + # | | | | | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + ml1 = f.MilestoneFactory.create(project=project) + ml2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project, sprint_order=1, milestone=ml1) + us2 = f.create_userstory(project=project, sprint_order=1, milestone=ml2) + us3 = f.create_userstory(project=project, sprint_order=2, milestone=ml2) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "after_userstory_id": None, + "milestone_id": ml1.id, + "bulk_userstories": [us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us2.id, + us3.id, + us1.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "milestone", "sprint_order") + .order_by("sprint_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us2.sprint_order == 1 + assert us2.milestone_id == ml1.id + assert us3.sprint_order == 2 + assert us3.milestone_id == ml1.id + assert us1.sprint_order == 3 + assert us1.milestone_id == ml1.id + + +def test_api_update_orders_in_bulk_succeeds_moved_to_a_milestone_to_the_middle(client): + # + # BLG | ML1 | ML2 | | BLG | ML1 | ML2 + # -------|-------|------- | | -------|-------|------- + # | us1 | us2 | MOVE: us1, us3 | | us4 | us2 + # | us4 | us3 | TO: milestone-1 | | us1 | + # | us5 | | AFTER: us4 | | us3 | + # | | | | | us5 | + # | | | | | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + ml1 = f.MilestoneFactory.create(project=project) + ml2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project, sprint_order=1, milestone=ml1) + us4 = f.create_userstory(project=project, sprint_order=2, milestone=ml1) + us5 = f.create_userstory(project=project, sprint_order=3, milestone=ml1) + us2 = f.create_userstory(project=project, sprint_order=1, milestone=ml2) + us3 = f.create_userstory(project=project, sprint_order=2, milestone=ml2) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "after_userstory_id": us4.id, + "milestone_id": ml1.id, + "bulk_userstories": [us1.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us1.id, + us3.id, + us5.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "milestone", "sprint_order") + .order_by("sprint_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + us4.refresh_from_db() + us5.refresh_from_db() + assert us4.sprint_order == 2 + assert us4.milestone_id == ml1.id + assert us1.sprint_order == 3 + assert us1.milestone_id == ml1.id + assert us3.sprint_order == 4 + assert us3.milestone_id == ml1.id + assert us5.sprint_order == 5 + assert us5.milestone_id == ml1.id + assert us2.sprint_order == 1 + assert us2.milestone_id == ml2.id + + +def test_api_update_orders_in_bulk_succeeds_moved_to_a_milestone_to_the_end(client): + # + # BLG | ML1 | ML2 | | BLG | ML1 | ML2 + # -------|-------|------- | | -------|-------|------- + # us1 | us2 | us3 | MOVE: us1, us2 | | | us3 + # | | | TO: milestone-2 | | | us1 + # | | | AFTER: us3 | | | us2 + # | | | | | | + # | | | | | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + ml1 = f.MilestoneFactory.create(project=project) + ml2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project, backlog_order=1, milestone=None) + us2 = f.create_userstory(project=project, sprint_order=1, milestone=ml1) + us3 = f.create_userstory(project=project, sprint_order=1, milestone=ml2) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "after_userstory_id": us3.id, + "milestone_id": ml2.id, + "bulk_userstories": [us1.id, + us2.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us1.id, + us2.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "milestone", "sprint_order") + .order_by("sprint_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us3.sprint_order == 1 + assert us3.milestone_id == ml2.id + assert us1.sprint_order == 2 + assert us1.milestone_id == ml2.id + assert us2.sprint_order == 3 + assert us2.milestone_id == ml2.id + + +def test_api_update_orders_in_bulk_succeeds_moved_to_a_milestone_before_one_us(client): + # + # BLG | ML1 | ML2 | | BLG | ML1 | ML2 + # -------|-------|------- | | -------|-------|------- + # | us1 | us3 | MOVE: us3 | | us1 | + # | us2 | | TO: milestone-1 | | us3 | + # | | | BEFORE: us2 | | us2 | + # | | | | | | + # | | | | | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + ml1 = f.MilestoneFactory.create(project=project) + ml2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project, sprint_order=1, milestone=ml1) + us2 = f.create_userstory(project=project, sprint_order=2, milestone=ml1) + us3 = f.create_userstory(project=project, sprint_order=1, milestone=ml2) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "before_userstory_id": us2.id, + "milestone_id": ml1.id, + "bulk_userstories": [us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us2.id, + us3.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "milestone", "sprint_order") + .order_by("sprint_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us1.sprint_order == 1 + assert us1.milestone_id == ml1.id + assert us3.sprint_order == 2 + assert us3.milestone_id == ml1.id + assert us2.sprint_order == 3 + assert us2.milestone_id == ml1.id + + +############################## +## Data errors +############################## + +def test_api_update_orders_in_bulk_invalid_userstories(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory() + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "bulk_userstories" in response.data + + +def test_api_update_orders_in_bulk_invalid_milestone(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + milestone = f.MilestoneFactory.create() + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "milestone_id": milestone.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "milestone_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_project(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory() + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "after_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_milestone(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + milestone = f.MilestonmeFactory.create(project=project) + milestone2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, milestone=milestone2) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "milestone_id": milestone.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.milestone_code == 400, response.data + assert len(response.data) == 1 + assert "after_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_no_milestone(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + milestone = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, milestone=None) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "milestone_id": milestone.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.milestone_code == 400, response.data + assert len(response.data) == 1 + assert "after_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_project(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory() + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "before_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "before_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_milestone(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + milestone = f.MilestoneFactory.create(project=project) + milestone2 = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, milestone=milestone2) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "milestone_id": milestone.id, + "before_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "before_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_no_milestone(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + milestone = f.MilestoneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, milestone=None) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "milestone_id": milestone.id, + "before_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "before_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_before_us_because_after_us_exist(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + us1 = f.create_userstory(project=project, milestone=None) + us2 = f.create_userstory(project=project, milestone=None) + us3 = f.create_userstory(project=project, milestone=None) + us4 = f.create_userstory(project=project, milestone=None) + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "before_userstory_id": us4.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "before_userstory_id" in response.data + + +############################## +## Close and/or Open Milestones after users stories are moved, history entries and webhooks should be created too +############################## + + +@mock.patch('taiga.webhooks.tasks._send_request') +def test_milestone_closed_changed_after_moving_userstories_in_bulk_to_other_milestone(send_request_mock, client, settings): + settings.WEBHOOKS_ENABLED = True + settings.CELERY_ENABLED = False + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status_opened = f.UserStoryStatusFactory.create(project=project, order=1, is_closed=False) + status_closed = f.UserStoryStatusFactory.create(project=project, order=2, is_closed=True) + + ml1 = f.MilestoneFactory.create(project=project, name="ML-1") + ml2 = f.MilestoneFactory.create(project=project, name="ML-2") + + us1 = f.create_userstory(project=project, status=status_closed, is_closed=True, milestone=ml1, sprint_order=1) + us2 = f.create_userstory(project=project, status=status_opened, is_closed=False, milestone=ml2, sprint_order=1) + us3 = f.create_userstory(project=project, status=status_opened, is_closed=False, milestone=ml2, sprint_order=2) + us4 = f.create_userstory(project=project, status=status_closed, is_closed=True, milestone=ml2, sprint_order=3) + + assert HistoryEntry.objects.all().count() == 0 + assert send_request_mock.call_count == 0 + + url = reverse("userstories-bulk-update-backlog-order") + + data = { + "project_id": project.id, + "after_userstory_id": us1.id, + "milestone_id": ml1.id, + "bulk_userstories": [us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us2.id, + us3.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "milestone", "sprint_order") + .order_by("sprint_order", "id")) + assert response.json() == list(res) + + ml1.refresh_from_db() + ml2.refresh_from_db() + assert not ml1.closed + assert ml2.closed + + assert HistoryEntry.objects.all().count() == 2 + assert send_request_mock.call_count == 2 diff --git a/tests/integration/test_userstories_update_kanban_order.py b/tests/integration/test_userstories_update_kanban_order.py new file mode 100644 index 000000000..2021561d1 --- /dev/null +++ b/tests/integration/test_userstories_update_kanban_order.py @@ -0,0 +1,1029 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import uuid +import csv +import pytz + +from datetime import datetime, timedelta +from urllib.parse import quote + +from unittest import mock +from django.urls import reverse + +from taiga.base.utils import json +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.choices import HistoryType +from taiga.projects.occ import OCCResourceMixin +from taiga.projects.userstories import services, models + + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db(transaction=True) + + +############################## +## Move to no swimlane +############################## + +def test_api_update_orders_in_bulk_succeeds_moved_to_no_swimlane_and_to_the_begining(client): + # + # | ST1 | ST2 | | | ST1 | ST2 + # -----|-------|------- | | -----|-------|------- + # | us1 | | MOVE: us2, us3 | | us2 | + # | us2 | | TO: no-swimlane | | us3 | + # -----|-------|------- | UNDER: st1 | | us1 | + # | | us3 | AFTER: bigining | -----|-------|------- + # SW1 | | | | | | + # | | | | SW1 | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status1 = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swimlane1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project, status=status1, kanban_order=1, swimlane=None) + us2 = f.create_userstory(project=project, status=status1, kanban_order=2, swimlane=None) + us3 = f.create_userstory(project=project, status=status2, kanban_order=1, swimlane=swimlane1) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status1.id, + "after_userstory_id": None, + "bulk_userstories": [us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us2.id, + us3.id, + us1.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us2.kanban_order == 1 + assert us2.swimlane_id == None + assert us2.status_id == status1.id + assert us3.kanban_order == 2 + assert us3.swimlane_id == None + assert us3.status_id == status1.id + assert us1.kanban_order == 3 + assert us1.swimlane_id == None + assert us1.status_id == status1.id + + +def test_api_update_orders_in_bulk_succeeds_moved_to_no_swimlane_and_to_the_middle(client): + # + # | ST1 | ST2 | | | ST1 | ST2 + # -----|-------|------- | | -----|-------|------- + # | us1 | | MOVE: us1, us3 | | us4 | + # | us4 | | TO: no-swimlane | | us1 | + # | us5 | | UNDER: st1 | | us3 | + # -----|-------|---------| AFTER: us4 | | us5 | + # SW1 | | us2 | | -----|-------|------- + # | | us3 | | SW1 | | us2 + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status1 = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swimlane1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project, status=status1, kanban_order=1, swimlane=None) + us2 = f.create_userstory(project=project, status=status2, kanban_order=2, swimlane=swimlane1) + us3 = f.create_userstory(project=project, status=status2, kanban_order=3, swimlane=swimlane1) + us4 = f.create_userstory(project=project, status=status1, kanban_order=4, swimlane=None) + us5 = f.create_userstory(project=project, status=status1, kanban_order=4, swimlane=None) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status1.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us1.id, + us3.id, + us5.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + us4.refresh_from_db() + us5.refresh_from_db() + assert us2.kanban_order == 2 + assert us2.swimlane_id == swimlane1.id + assert us2.status_id == status2.id + assert us4.kanban_order == 4 + assert us4.swimlane_id == None + assert us4.status_id == status1.id + assert us1.kanban_order == 5 + assert us1.swimlane_id == None + assert us1.status_id == status1.id + assert us3.kanban_order == 6 + assert us3.swimlane_id == None + assert us3.status_id == status1.id + assert us5.kanban_order == 7 + assert us5.swimlane_id == None + assert us5.status_id == status1.id + + +def test_api_update_orders_in_bulk_succeeds_moved_to_no_swimlane_and_to_the_end(client): + # + # | ST1 | ST2 | | | ST1 | ST2 + # -----|-------|------- | | -----|-------|------- + # | us1 | | MOVE: us3 | | us1 | + # | us2 | | TO: no-swimlane | | us2 | + # -----|-------|------- | UNDER: st1 | | us3 | + # | | us3 | AFTER: us2 | -----|-------|------- + # SW1 | | | | | | + # | | | | SW1 | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status1 = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swimlane1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project, status=status1, kanban_order=1, swimlane=None) + us2 = f.create_userstory(project=project, status=status1, kanban_order=2, swimlane=None) + us3 = f.create_userstory(project=project, status=status2, kanban_order=1, swimlane=swimlane1) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status1.id, + "after_userstory_id": us2.id, + "bulk_userstories": [us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us3.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us1.kanban_order == 1 + assert us1.swimlane_id == None + assert us1.status_id == status1.id + assert us2.kanban_order == 2 + assert us2.swimlane_id == None + assert us2.status_id == status1.id + assert us3.kanban_order == 3 + assert us3.swimlane_id == None + assert us3.status_id == status1.id + +def test_api_update_orders_in_bulk_succeeds_moved_to_no_swimlane_and_before_a_us(client): + # + # | ST1 | ST2 | | | ST1 | ST2 + # -----|-------|------- | | -----|-------|------- + # | us1 | | MOVE: us3 | | us1 | + # | us2 | | TO: no-swimlane | | us3 | + # -----|-------|------- | UNDER: st1 | | us2 | + # | | us3 | BEFORE: us2 | -----|-------|------- + # SW1 | | | | | | + # | | | | SW1 | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status1 = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swimlane1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project, status=status1, kanban_order=1, swimlane=None) + us2 = f.create_userstory(project=project, status=status1, kanban_order=2, swimlane=None) + us3 = f.create_userstory(project=project, status=status2, kanban_order=1, swimlane=swimlane1) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status1.id, + "before_userstory_id": us2.id, + "bulk_userstories": [us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us2.id, + us3.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us1.kanban_order == 1 + assert us1.swimlane_id == None + assert us1.status_id == status1.id + assert us3.kanban_order == 2 + assert us3.swimlane_id == None + assert us3.status_id == status1.id + assert us2.kanban_order == 3 + assert us2.swimlane_id == None + assert us2.status_id == status1.id + +############################## +## Move to swimlane +############################## + + +def test_api_update_orders_in_bulk_succeeds_moved_to_a_swimlane_and_to_the_begining(client): + # + # | ST1 | ST2 | | | ST1 | ST2 + # -----|-------|------- | | -----|-------|------- + # SW1 | us1 | | MOVE: us2, us3 | | us2 | + # | us2 | | TO: sw1 | SW1 | us3 | + # -----|-------|------- | UNDER: st1 | | us1 | + # | | us3 | AFTER: bigining | -----|-------|------- + # SW2 | | | | | | + # | | | | SW2 | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status1 = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swimlane1 = f.SwimlaneFactory.create(project=project) + swimlane2 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project, status=status1, kanban_order=1, swimlane=swimlane1) + us2 = f.create_userstory(project=project, status=status1, kanban_order=2, swimlane=swimlane1) + us3 = f.create_userstory(project=project, status=status2, kanban_order=1, swimlane=swimlane2) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "swimlane_id": swimlane1.id, + "status_id": status1.id, + "bulk_userstories": [us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us2.id, + us3.id, + us1.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us2.kanban_order == 1 + assert us2.swimlane_id == swimlane1.id + assert us2.status_id == status1.id + assert us3.kanban_order == 2 + assert us3.swimlane_id == swimlane1.id + assert us3.status_id == status1.id + assert us1.kanban_order == 3 + assert us1.swimlane_id == swimlane1.id + assert us1.status_id == status1.id + + +def test_api_update_orders_in_bulk_succeeds_moved_to_a_swimlane_and_to_the_middle(client): + # + # | ST1 | ST2 | | | ST1 | ST2 + # -----|-------|------- | | -----|-------|------- + # | us1 | | MOVE: us1, us3 | | us4 | + # sw1 | us4 | | TO: sw1 | SW1 | us1 | + # | us5 | | UNDER: st1 | | us3 | + # -----|-------|---------| AFTER: us4 | | us5 | + # SW2 | | us2 | | -----|-------|------- + # | | us3 | | SW1 | | us2 + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status1 = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swimlane1 = f.SwimlaneFactory.create(project=project) + swimlane2 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project, status=status1, kanban_order=1, swimlane=swimlane1) + us2 = f.create_userstory(project=project, status=status2, kanban_order=2, swimlane=swimlane2) + us3 = f.create_userstory(project=project, status=status2, kanban_order=3, swimlane=swimlane2) + us4 = f.create_userstory(project=project, status=status1, kanban_order=4, swimlane=swimlane1) + us5 = f.create_userstory(project=project, status=status1, kanban_order=4, swimlane=swimlane1) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "swimlane_id": swimlane1.id, + "status_id": status1.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us1.id, + us3.id, + us5.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + us4.refresh_from_db() + us5.refresh_from_db() + assert us2.kanban_order == 2 + assert us2.swimlane_id == swimlane2.id + assert us2.status_id == status2.id + assert us4.kanban_order == 4 + assert us4.swimlane_id == swimlane1.id + assert us4.status_id == status1.id + assert us1.kanban_order == 5 + assert us1.swimlane_id == swimlane1.id + assert us1.status_id == status1.id + assert us3.kanban_order == 6 + assert us3.swimlane_id == swimlane1.id + assert us3.status_id == status1.id + assert us5.kanban_order == 7 + assert us5.swimlane_id == swimlane1.id + assert us5.status_id == status1.id + + +def test_api_update_orders_in_bulk_succeeds_moved_to_a_swimlane_and_to_the_end(client): + # + # | ST1 | ST2 | | | ST1 | ST2 + # -----|-------|------- | | -----|-------|------- + # SW1 | us1 | | MOVE: us3 | | us1 | + # | us2 | | TO: sw1 | SW1 | us2 | + # -----|-------|------- | UNDER: st1 | | us3 | + # | | us3 | AFTER: us2 | -----|-------|------- + # SW2 | | | | | | + # | | | | SW2 | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status1 = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swimlane1 = f.SwimlaneFactory.create(project=project) + swimlane2 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project, status=status1, kanban_order=1, swimlane=swimlane1) + us2 = f.create_userstory(project=project, status=status1, kanban_order=2, swimlane=swimlane1) + us3 = f.create_userstory(project=project, status=status2, kanban_order=1, swimlane=swimlane2) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "swimlane_id": swimlane1.id, + "status_id": status1.id, + "after_userstory_id": us2.id, + "bulk_userstories": [us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us3.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us1.kanban_order == 1 + assert us1.swimlane_id == swimlane1.id + assert us1.status_id == status1.id + assert us2.kanban_order == 2 + assert us2.swimlane_id == swimlane1.id + assert us2.status_id == status1.id + assert us3.kanban_order == 3 + assert us3.swimlane_id == swimlane1.id + assert us3.status_id == status1.id + +def test_api_update_orders_in_bulk_succeeds_moved_to_a_swimlane_and_before_a_us(client): + # + # | ST1 | ST2 | | | ST1 | ST2 + # -----|-------|------- | | -----|-------|------- + # SW1 | us1 | | MOVE: us3 | | us1 | + # | us2 | | TO: sw1 | SW1 | us3 | + # -----|-------|------- | UNDER: st1 | | us2 | + # | | us3 | BEFORE: us2 | -----|-------|------- + # SW2 | | | | | | + # | | | | SW2 | | + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status1 = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swimlane1 = f.SwimlaneFactory.create(project=project) + swimlane2 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project, status=status1, kanban_order=1, swimlane=swimlane1) + us2 = f.create_userstory(project=project, status=status1, kanban_order=2, swimlane=swimlane1) + us3 = f.create_userstory(project=project, status=status2, kanban_order=1, swimlane=swimlane2) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "swimlane_id": swimlane1.id, + "status_id": status1.id, + "before_userstory_id": us2.id, + "bulk_userstories": [us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us3.id, + us2.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + us1.refresh_from_db() + us2.refresh_from_db() + us3.refresh_from_db() + assert us1.kanban_order == 1 + assert us1.swimlane_id == swimlane1.id + assert us1.status_id == status1.id + assert us3.kanban_order == 2 + assert us3.swimlane_id == swimlane1.id + assert us3.status_id == status1.id + assert us2.kanban_order == 3 + assert us2.swimlane_id == swimlane1.id + assert us2.status_id == status1.id + + +############################## +## Data errors +############################## + +def test_api_update_orders_in_bulk_invalid_userstories(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory() + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "bulk_userstories" in response.data + + +def test_api_update_orders_in_bulk_invalid_status(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create() + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "status_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_swimlane(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create() + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "swimlane_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_project(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory() + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "after_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_status(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, swimlane=swl1, status=status2) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "after_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_swimlane(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + swl2 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, status=status, swimlane=swl2) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "after_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_after_us_because_no_swimlane(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, status=status, swimlane=None) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "after_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_before_us_because_project(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory() + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "before_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "before_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_before_us_because_status(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + status2 = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, swimlane=swl1, status=status2) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "before_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "before_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_before_us_because_swimlane(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + swl2 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, status=status, swimlane=swl2) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "before_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "before_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_before_us_because_no_swimlane(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, status=status, swimlane=None) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "before_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "before_userstory_id" in response.data + + +def test_api_update_orders_in_bulk_invalid_before_us_because_after_us_exist(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status = f.UserStoryStatusFactory.create(project=project) + swl1 = f.SwimlaneFactory.create(project=project) + us1 = f.create_userstory(project=project) + us2 = f.create_userstory(project=project) + us3 = f.create_userstory(project=project) + us4 = f.create_userstory(project=project, swimlane=swl1, status=status) + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status.id, + "swimlane_id": swl1.id, + "before_userstory_id": us4.id, + "after_userstory_id": us4.id, + "bulk_userstories": [us1.id, + us2.id, + us3.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400, response.data + assert len(response.data) == 1 + assert "before_userstory_id" in response.data + + + +############################## +## Move after user story status is deleted +############################## + +def test_api_delete_userstory_status(client): + # + # | ST1 | ST2 | | | ST1 + # -----|-------|------- | | -----|------- + # SW1 | us111 | us121 | DELETE: st2 | | us111 + # | us112 | us122 | AND MOVE TO: st1 | SW1 | us112 + # -----|-------|------- | | | us121 + # | us211 | us221 | | | us122 + # SW2 | | | | -----|------- + # | | | | SW2 | us211 + # | | | | | us222 + + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + st1 = f.UserStoryStatusFactory.create(project=project, order=1) + st2 = f.UserStoryStatusFactory.create(project=project, order=2) + swl1 = f.SwimlaneFactory.create(project=project, order=1) + swl2 = f.SwimlaneFactory.create(project=project, order=2) + us111 = f.create_userstory(project=project, swimlane=swl1, status=st1, kanban_order=20) + us112 = f.create_userstory(project=project, swimlane=swl1, status=st1, kanban_order=40) + us121 = f.create_userstory(project=project, swimlane=swl1, status=st2, kanban_order=35) + us122 = f.create_userstory(project=project, swimlane=swl1, status=st2, kanban_order=45) + us211 = f.create_userstory(project=project, swimlane=swl2, status=st1, kanban_order=2) + us221 = f.create_userstory(project=project, swimlane=swl2, status=st2, kanban_order=1) + + uss_qs = (models.UserStory.objects.values_list("id", flat=True) + .order_by("status__order", "swimlane__order", "kanban_order")) + url = reverse("userstory-statuses-detail", kwargs={"pk": st2.pk}) + f"?moveTo={st1.id}" + assert list(uss_qs) == [us111.id, us112.id, us211.id, us121.id, us122.id, us221.id] + + client.login(project.owner) + response = client.json.delete(url) + assert response.status_code == 204, response.data + + uss_qs = (models.UserStory.objects.values_list("id", flat=True) + .order_by("status__order", "swimlane__order", "kanban_order")) + assert list(uss_qs) == [us111.id, us112.id, us121.id, us122.id, us211.id, us221.id] + + +############################## +## Close and Open USs after they are moved, history entries and webhooks should be created too +############################## + +@mock.patch('taiga.webhooks.tasks._send_request') +def test_userstories_are_closed_after_moving_in_bulk_to_a_closed_status(send_request_mock, client, settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status_opened = f.UserStoryStatusFactory.create(project=project, order=1, is_closed=False) + status_closed = f.UserStoryStatusFactory.create(project=project, order=2, is_closed=True) + us1 = f.create_userstory(project=project, status=status_closed, is_closed=True, kanban_order=3) + us2 = f.create_userstory(project=project, status=status_opened, is_closed=False, kanban_order=2) + + assert HistoryEntry.objects.all().count() == 0 + assert send_request_mock.call_count == 0 + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status_closed.id, + "after_userstory_id": None, + "bulk_userstories": [us1.id, + us2.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us1.id, + us2.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + + assert us1.is_closed and us1.status == status_closed + assert not us2.is_closed and us2.status == status_opened + us1.refresh_from_db() + us2.refresh_from_db() + assert us1.is_closed and us1.status == status_closed + assert us2.is_closed and us2.status == status_closed + assert HistoryEntry.objects.all().count() == 2 + assert send_request_mock.call_count == 2 + + +@mock.patch('taiga.webhooks.tasks._send_request') +def test_userstories_are_opened_after_moving_in_bulk_to_a_opened_status(send_request_mock, client, settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) + status_opened = f.UserStoryStatusFactory.create(project=project, order=1, is_closed=False) + status_closed = f.UserStoryStatusFactory.create(project=project, order=2, is_closed=True) + us1 = f.create_userstory(project=project, status=status_closed, is_closed=True, kanban_order=3) + us2 = f.create_userstory(project=project, status=status_opened, is_closed=False, kanban_order=2) + + assert HistoryEntry.objects.all().count() == 0 + assert send_request_mock.call_count == 0 + + url = reverse("userstories-bulk-update-kanban-order") + + data = { + "project_id": project.id, + "status_id": status_opened.id, + "after_userstory_id": None, + "bulk_userstories": [us1.id, + us2.id] + } + + client.login(project.owner) + + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200, response.data + + updated_ids = [ + us1.id, + us2.id, + ] + res = (project.user_stories.filter(id__in=updated_ids) + .values("id", "swimlane", "kanban_order", "status") + .order_by("kanban_order", "id")) + assert response.json() == list(res) + + + assert us1.is_closed and us1.status == status_closed + assert not us2.is_closed and us2.status == status_opened + us1.refresh_from_db() + us2.refresh_from_db() + assert not us1.is_closed and us1.status == status_opened + assert not us2.is_closed and us2.status == status_opened + assert HistoryEntry.objects.all().count() == 2 + assert send_request_mock.call_count == 2 diff --git a/tests/integration/test_vote_issues.py b/tests/integration/test_vote_issues.py new file mode 100644 index 000000000..fd7536e51 --- /dev/null +++ b/tests/integration/test_vote_issues.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from django.urls import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_upvote_issue(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + url = reverse("issues-upvote", args=(issue.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_downvote_issue(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + url = reverse("issues-downvote", args=(issue.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_issue_voters(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + f.VoteFactory.create(content_object=issue, user=user) + url = reverse("issue-voters-list", args=(issue.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + +def test_get_issue_voter(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + vote = f.VoteFactory.create(content_object=issue, user=user) + url = reverse("issue-voters-detail", args=(issue.id, vote.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == vote.user.id + +def test_get_issue_votes(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + url = reverse("issues-detail", args=(issue.id,)) + + f.VotesFactory.create(content_object=issue, count=5) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['total_voters'] == 5 + + +def test_get_issue_is_voted(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + f.VotesFactory.create(content_object=issue) + url_detail = reverse("issues-detail", args=(issue.id,)) + url_upvote = reverse("issues-upvote", args=(issue.id,)) + url_downvote = reverse("issues-downvote", args=(issue.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_voters'] == 0 + assert response.data['is_voter'] == False + + response = client.post(url_upvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_voters'] == 1 + assert response.data['is_voter'] == True + + response = client.post(url_downvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_voters'] == 0 + assert response.data['is_voter'] == False diff --git a/tests/integration/test_vote_tasks.py b/tests/integration/test_vote_tasks.py new file mode 100644 index 000000000..f74fb8d7e --- /dev/null +++ b/tests/integration/test_vote_tasks.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from django.urls import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_upvote_task(client): + user = f.UserFactory.create() + task = f.create_task(owner=user, milestone=None) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + url = reverse("tasks-upvote", args=(task.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_downvote_task(client): + user = f.UserFactory.create() + task = f.create_task(owner=user, milestone=None) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + url = reverse("tasks-downvote", args=(task.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_task_voters(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + f.VoteFactory.create(content_object=task, user=user) + url = reverse("task-voters-list", args=(task.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_task_voter(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + vote = f.VoteFactory.create(content_object=task, user=user) + url = reverse("task-voters-detail", args=(task.id, vote.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == vote.user.id + + +def test_get_task_votes(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + url = reverse("tasks-detail", args=(task.id,)) + + f.VotesFactory.create(content_object=task, count=5) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['total_voters'] == 5 + + +def test_get_task_is_voted(client): + user = f.UserFactory.create() + task = f.create_task(owner=user, milestone=None) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + f.VotesFactory.create(content_object=task) + url_detail = reverse("tasks-detail", args=(task.id,)) + url_upvote = reverse("tasks-upvote", args=(task.id,)) + url_downvote = reverse("tasks-downvote", args=(task.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_voters'] == 0 + assert response.data['is_voter'] == False + + response = client.post(url_upvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_voters'] == 1 + assert response.data['is_voter'] == True + + response = client.post(url_downvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_voters'] == 0 + assert response.data['is_voter'] == False diff --git a/tests/integration/test_vote_userstories.py b/tests/integration/test_vote_userstories.py new file mode 100644 index 000000000..1e53a2c5e --- /dev/null +++ b/tests/integration/test_vote_userstories.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from django.urls import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_upvote_user_story(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user, status=None) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + url = reverse("userstories-upvote", args=(user_story.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_downvote_user_story(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user, status=None) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + url = reverse("userstories-downvote", args=(user_story.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_user_story_voters(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + f.VoteFactory.create(content_object=user_story, user=user) + url = reverse("userstory-voters-list", args=(user_story.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + +def test_get_userstory_voter(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + vote = f.VoteFactory.create(content_object=user_story, user=user) + url = reverse("userstory-voters-detail", args=(user_story.id, vote.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == vote.user.id + + +def test_get_user_story_votes(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + url = reverse("userstories-detail", args=(user_story.id,)) + + f.VotesFactory.create(content_object=user_story, count=5) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['total_voters'] == 5 + + +def test_get_user_story_is_voted(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user, status=None) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + f.VotesFactory.create(content_object=user_story) + url_detail = reverse("userstories-detail", args=(user_story.id,)) + url_upvote = reverse("userstories-upvote", args=(user_story.id,)) + url_downvote = reverse("userstories-downvote", args=(user_story.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_voters'] == 0 + assert response.data['is_voter'] == False + + response = client.post(url_upvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_voters'] == 1 + assert response.data['is_voter'] == True + + response = client.post(url_downvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_voters'] == 0 + assert response.data['is_voter'] == False diff --git a/tests/integration/test_votes.py b/tests/integration/test_votes.py new file mode 100644 index 000000000..6749f46bf --- /dev/null +++ b/tests/integration/test_votes.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.contrib.contenttypes.models import ContentType + +from taiga.projects.votes import services as votes, models + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_add_vote(): + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + user = f.UserFactory() + votes_qs = models.Votes.objects.filter(content_type=project_type, object_id=project.id) + + votes.add_vote(project, user) + + assert votes_qs.get().count == 1 + + votes.add_vote(project, user) # add_vote must be idempotent + + assert votes_qs.get().count == 1 + + +def test_remove_vote(): + user = f.UserFactory() + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + votes_qs = models.Votes.objects.filter(content_type=project_type, object_id=project.id) + f.VotesFactory(content_type=project_type, object_id=project.id, count=1) + f.VoteFactory(content_type=project_type, object_id=project.id, user=user) + + assert votes_qs.get().count == 1 + + votes.remove_vote(project, user) + + assert votes_qs.get().count == 0 + + votes.remove_vote(project, user) # remove_vote must be idempotent + + assert votes_qs.get().count == 0 + + +def test_get_votes(): + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + f.VotesFactory(content_type=project_type, object_id=project.id, count=4) + + assert votes.get_votes(project) == 4 + + +def test_get_voters(): + f.UserFactory() + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + vote = f.VoteFactory(content_type=project_type, object_id=project.id) + + assert list(votes.get_voters(project)) == [vote.user] + + +def test_get_voted(): + f.ProjectFactory() + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + vote = f.VoteFactory(content_type=project_type, object_id=project.id) + + assert list(votes.get_voted(vote.user, type(project))) == [project] diff --git a/tests/integration/test_watch_issues.py b/tests/integration/test_watch_issues.py new file mode 100644 index 000000000..c71539d41 --- /dev/null +++ b/tests/integration/test_watch_issues.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json +from django.urls import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_watch_issue(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + url = reverse("issues-watch", args=(issue.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_issue(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + url = reverse("issues-watch", args=(issue.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_issue_watchers(client): + user = f.UserFactory.create() + issue = f.IssueFactory(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + f.WatchedFactory.create(content_object=issue, user=user) + url = reverse("issue-watchers-list", args=(issue.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_issue_watcher(client): + user = f.UserFactory.create() + issue = f.IssueFactory(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + watch = f.WatchedFactory.create(content_object=issue, user=user) + url = reverse("issue-watchers-detail", args=(issue.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_issue_watchers(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + url = reverse("issues-detail", args=(issue.id,)) + + f.WatchedFactory.create(content_object=issue, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['total_watchers'] == 1 + + +def test_get_issue_is_watcher(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_admin=True) + url_detail = reverse("issues-detail", args=(issue.id,)) + url_watch = reverse("issues-watch", args=(issue.id,)) + url_unwatch = reverse("issues-unwatch", args=(issue.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watcher'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['is_watcher'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watcher'] == False + + +def test_remove_issue_watcher(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + issue = f.IssueFactory(project=project, + status__project=project, + severity__project=project, + priority__project=project, + type__project=project, + milestone__project=project) + + issue.add_watcher(user) + role = f.RoleFactory.create(project=project, permissions=['modify_issue', 'view_issues']) + f.MembershipFactory.create(project=project, user=user, role=role) + + url = reverse("issues-detail", args=(issue.id,)) + + client.login(user) + + data = {"version": issue.version, "watchers": []} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watcher'] == False diff --git a/tests/integration/test_watch_milestones.py b/tests/integration/test_watch_milestones.py new file mode 100644 index 000000000..74c5b5004 --- /dev/null +++ b/tests/integration/test_watch_milestones.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json +from django.urls import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_watch_milestone(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_admin=True) + url = reverse("milestones-watch", args=(milestone.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_milestone(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_admin=True) + url = reverse("milestones-watch", args=(milestone.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_milestone_watchers(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_admin=True) + f.WatchedFactory.create(content_object=milestone, user=user) + url = reverse("milestone-watchers-list", args=(milestone.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_milestone_watcher(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_admin=True) + watch = f.WatchedFactory.create(content_object=milestone, user=user) + url = reverse("milestone-watchers-detail", args=(milestone.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id diff --git a/tests/integration/test_watch_projects.py b/tests/integration/test_watch_projects.py new file mode 100644 index 000000000..80f8c6d54 --- /dev/null +++ b/tests/integration/test_watch_projects.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json +from django.urls import reverse + +from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_watch_project(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("projects-watch", args=(project.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_watch_project_with_valid_notify_level(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("projects-watch", args=(project.id,)) + + client.login(user) + data = { + "notify_level": 1 + } + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + + +def test_watch_project_with_invalid_notify_level(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("projects-watch", args=(project.id,)) + + client.login(user) + data = { + "notify_level": 333 + } + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert response.data["_error_message"] == "Invalid value for notify level" + + +def test_unwatch_project(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("projects-unwatch", args=(project.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_project_watchers(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + f.WatchedFactory.create(content_object=project, user=user) + url = reverse("project-watchers-list", args=(project.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_project_watcher(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + watch = f.WatchedFactory.create(content_object=project, user=user) + url = reverse("project-watchers-detail", args=(project.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_project_watchers(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + url = reverse("projects-detail", args=(project.id,)) + + f.WatchedFactory.create(content_object=project, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['total_watchers'] == 1 + + +def test_get_project_is_watcher(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS))) + + url_detail = reverse("projects-detail", args=(project.id,)) + url_watch = reverse("projects-watch", args=(project.id,)) + url_unwatch = reverse("projects-unwatch", args=(project.id,)) + + client.login(user) + + response = client.get(url_detail) + + assert response.status_code == 200 + assert response.data['total_watchers'] == 0 + + assert response.data['is_watcher'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_watchers'] == 1 + assert response.data['is_watcher'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_watchers'] == 0 + assert response.data['is_watcher'] == False diff --git a/tests/integration/test_watch_tasks.py b/tests/integration/test_watch_tasks.py new file mode 100644 index 000000000..4b6d55886 --- /dev/null +++ b/tests/integration/test_watch_tasks.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json +from django.urls import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_watch_task(client): + user = f.UserFactory.create() + task = f.create_task(owner=user, milestone=None) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + url = reverse("tasks-watch", args=(task.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_task(client): + user = f.UserFactory.create() + task = f.create_task(owner=user, milestone=None) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + url = reverse("tasks-watch", args=(task.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_task_watchers(client): + user = f.UserFactory.create() + task = f.TaskFactory(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + f.WatchedFactory.create(content_object=task, user=user) + url = reverse("task-watchers-list", args=(task.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_task_watcher(client): + user = f.UserFactory.create() + task = f.TaskFactory(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + watch = f.WatchedFactory.create(content_object=task, user=user) + url = reverse("task-watchers-detail", args=(task.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_task_watchers(client): + user = f.UserFactory.create() + task = f.TaskFactory(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + url = reverse("tasks-detail", args=(task.id,)) + + f.WatchedFactory.create(content_object=task, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['total_watchers'] == 1 + + +def test_get_task_is_watcher(client): + user = f.UserFactory.create() + task = f.create_task(owner=user, milestone=None) + f.MembershipFactory.create(project=task.project, user=user, is_admin=True) + url_detail = reverse("tasks-detail", args=(task.id,)) + url_watch = reverse("tasks-watch", args=(task.id,)) + url_unwatch = reverse("tasks-unwatch", args=(task.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watcher'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['is_watcher'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watcher'] == False + + +def test_remove_task_watcher(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + task = f.TaskFactory(project=project, + user_story=None, + status__project=project, + milestone__project=project) + + task.add_watcher(user) + role = f.RoleFactory.create(project=project, permissions=['modify_task', 'view_tasks']) + f.MembershipFactory.create(project=project, user=user, role=role) + + url = reverse("tasks-detail", args=(task.id,)) + + client.login(user) + + data = {"version": task.version, "watchers": []} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watcher'] == False diff --git a/tests/integration/test_watch_userstories.py b/tests/integration/test_watch_userstories.py new file mode 100644 index 000000000..a088d55ff --- /dev/null +++ b/tests/integration/test_watch_userstories.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json +from django.urls import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_watch_user_story(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user, status=None) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + url = reverse("userstories-watch", args=(user_story.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_user_story(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user, status=None) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + url = reverse("userstories-unwatch", args=(user_story.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_user_story_watchers(client): + user = f.UserFactory.create() + user_story = f.UserStoryFactory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + f.WatchedFactory.create(content_object=user_story, user=user) + url = reverse("userstory-watchers-list", args=(user_story.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_user_story_watcher(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user, status=None) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + watch = f.WatchedFactory.create(content_object=user_story, user=user) + url = reverse("userstory-watchers-detail", args=(user_story.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_user_story_watchers(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user, status=None) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + url = reverse("userstories-detail", args=(user_story.id,)) + + f.WatchedFactory.create(content_object=user_story, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['total_watchers'] == 1 + + +def test_get_user_story_is_watcher(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user, status=None) + f.MembershipFactory.create(project=user_story.project, user=user, is_admin=True) + url_detail = reverse("userstories-detail", args=(user_story.id,)) + url_watch = reverse("userstories-watch", args=(user_story.id,)) + url_unwatch = reverse("userstories-unwatch", args=(user_story.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watcher'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['is_watcher'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watcher'] == False + + +def test_remove_user_story_watcher(client): + user = f.UserFactory.create() + project = f.create_project() + + us = f.create_userstory(project=project, + status__project=project, + milestone__project=project) + + us.add_watcher(user) + role = f.RoleFactory.create(project=project, permissions=['modify_us', 'view_us']) + f.MembershipFactory.create(project=project, user=user, role=role) + + url = reverse("userstories-detail", args=(us.id,)) + + client.login(user) + + data = {"version": us.version, "watchers": []} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watcher'] == False diff --git a/tests/integration/test_watch_wikipages.py b/tests/integration/test_watch_wikipages.py new file mode 100644 index 000000000..52b6cd52e --- /dev/null +++ b/tests/integration/test_watch_wikipages.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import json +from django.urls import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_watch_wikipage(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_admin=True) + url = reverse("wiki-watch", args=(wikipage.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_wikipage(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_admin=True) + url = reverse("wiki-watch", args=(wikipage.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_wikipage_watchers(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_admin=True) + f.WatchedFactory.create(content_object=wikipage, user=user) + url = reverse("wiki-watchers-list", args=(wikipage.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_wikipage_watcher(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_admin=True) + watch = f.WatchedFactory.create(content_object=wikipage, user=user) + url = reverse("wiki-watchers-detail", args=(wikipage.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_wikipage_watchers(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_admin=True) + url = reverse("wiki-detail", args=(wikipage.id,)) + + f.WatchedFactory.create(content_object=wikipage, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['total_watchers'] == 1 + + +def test_get_wikipage_is_watcher(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_admin=True) + url_detail = reverse("wiki-detail", args=(wikipage.id,)) + url_watch = reverse("wiki-watch", args=(wikipage.id,)) + url_unwatch = reverse("wiki-unwatch", args=(wikipage.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_watchers'] == 0 + assert response.data['is_watcher'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_watchers'] == 1 + assert response.data['is_watcher'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_watchers'] == 0 + assert response.data['is_watcher'] == False diff --git a/tests/integration/test_webhook_serializers.py b/tests/integration/test_webhook_serializers.py new file mode 100644 index 000000000..19f125e9f --- /dev/null +++ b/tests/integration/test_webhook_serializers.py @@ -0,0 +1,12 @@ +import taiga.webhooks.serializers + + +class TestCustomAttributesValuesWebhookSerializerMixin: + webhook_serializer_mixin = ( + taiga.webhooks.serializers.CustomAttributesValuesWebhookSerializerMixin + ) + + def test_get_custom_attributes_values(self): + assert ( + self.webhook_serializer_mixin().get_custom_attributes_values(None) is None + ) diff --git a/tests/integration/test_webhooks.py b/tests/integration/test_webhooks.py new file mode 100644 index 000000000..168053ac5 --- /dev/null +++ b/tests/integration/test_webhooks.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.urls import reverse +from unittest.mock import patch +from unittest.mock import Mock + +from taiga.base.utils import json + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.project_owner = f.UserFactory.create() + + m.project1 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + f.MembershipFactory(project=m.project1, + user=m.project_owner, + is_admin=True) + m.webhook1 = f.WebhookFactory(project=m.project1) + m.webhooklog1 = f.WebhookLogFactory(webhook=m.webhook1) + + return m + + +def test_webhook_action_test_transform_to_json(client, data): + url = reverse('webhooks-test', kwargs={"pk": data.webhook1.pk}) + + response = Mock(status_code=200, headers={}, text="ok") + response.elapsed.total_seconds.return_value = 100 + + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response), \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + client.login(data.project_owner) + response = client.json.post(url) + assert response.status_code == 200 + assert json.loads(response.data["response_data"]) == {"content": "ok"} diff --git a/tests/integration/test_webhooks_epics.py b/tests/integration/test_webhooks_epics.py new file mode 100644 index 000000000..d5d4ffe6c --- /dev/null +++ b/tests/integration/test_webhooks_epics.py @@ -0,0 +1,307 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest.mock import patch + +from .. import factories as f + +from taiga.projects.history import services + + +pytestmark = pytest.mark.django_db(transaction=True) + + +def test_webhooks_when_create_epic(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "create" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + + +def test_webhooks_when_update_epic(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + obj.subject = "test webhook update" + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["data"]["subject"] == obj.subject + assert data["change"]["comment"] == "test_comment" + assert data["change"]["diff"]["subject"]["to"] == data["data"]["subject"] + assert data["change"]["diff"]["subject"]["from"] != data["data"]["subject"] + + +def test_webhooks_when_delete_epic(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, delete=True) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "delete" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert "data" in data + + +def test_webhooks_when_update_epic_attachments(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create attachments + attachment1 = f.EpicAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + attachment2 = f.EpicAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 2 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Update attachment + attachment1.description = "new attachment description" + attachment1.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 1 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Delete attachment + attachment2.delete() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 1 + + +def test_webhooks_when_update_epic_custom_attributes(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.EpicFactory.create(project=project) + + custom_attr_1 = f.EpicCustomAttributeFactory(project=obj.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.EpicCustomAttributeFactory(project=obj.project) + ct2_id = "{}".format(custom_attr_2.id) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create custom attributes + obj.custom_attributes_values.attributes_values = { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + } + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 2 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Update custom attributes + obj.custom_attributes_values.attributes_values[ct1_id] = "test_2_updated" + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 1 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Delete custom attributes + del obj.custom_attributes_values.attributes_values[ct1_id] + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "epic" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 1 + + +def test_webhooks_when_create_epic_related_userstory(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + epic = f.EpicFactory.create(project=project) + obj = f.RelatedUserStory.create(epic=epic) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=epic.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "create" + assert data["type"] == "relateduserstory" + assert data["by"]["id"] == epic.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + + +def test_webhooks_when_update_epic_related_userstory(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + epic = f.EpicFactory.create(project=project) + obj = f.RelatedUserStory.create(epic=epic, order=33) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=epic.owner) + assert send_request_mock.call_count == 2 + + obj.order = 66 + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=epic.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "relateduserstory" + assert data["by"]["id"] == epic.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["data"]["order"] == obj.order + assert data["change"]["diff"]["order"]["to"] == 66 + assert data["change"]["diff"]["order"]["from"] == 33 + + +def test_webhooks_when_delete_epic_related_userstory(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + epic = f.EpicFactory.create(project=project) + obj = f.RelatedUserStory.create(epic=epic, order=33) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=epic.owner, delete=True) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "delete" + assert data["type"] == "relateduserstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert "data" in data diff --git a/tests/integration/test_webhooks_issues.py b/tests/integration/test_webhooks_issues.py new file mode 100644 index 000000000..52e133b42 --- /dev/null +++ b/tests/integration/test_webhooks_issues.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest.mock import patch + +from .. import factories as f + +from taiga.projects.history import services + + +pytestmark = pytest.mark.django_db(transaction=True) + + +def test_webhooks_when_create_issue(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.IssueFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "create" + assert data["type"] == "issue" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + + +def test_webhooks_when_update_issue(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.IssueFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + obj.subject = "test webhook update" + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "issue" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["data"]["subject"] == obj.subject + assert data["change"]["comment"] == "test_comment" + assert data["change"]["diff"]["subject"]["to"] == data["data"]["subject"] + assert data["change"]["diff"]["subject"]["from"] != data["data"]["subject"] + + +def test_webhooks_when_delete_issue(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.IssueFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, delete=True) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "delete" + assert data["type"] == "issue" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert "data" in data + + +def test_webhooks_when_update_issue_attachments(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.IssueFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create attachments + attachment1 = f.IssueAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + attachment2 = f.IssueAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "issue" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 2 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Update attachment + attachment1.description = "new attachment description" + attachment1.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "issue" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 1 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Delete attachment + attachment2.delete() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "issue" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 1 + + +def test_webhooks_when_update_issue_custom_attributes(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.IssueFactory.create(project=project) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=obj.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=obj.project) + ct2_id = "{}".format(custom_attr_2.id) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create custom attributes + obj.custom_attributes_values.attributes_values = { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + } + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "issue" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 2 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Update custom attributes + obj.custom_attributes_values.attributes_values[ct1_id] = "test_2_updated" + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "issue" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 1 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Delete custom attributes + del obj.custom_attributes_values.attributes_values[ct1_id] + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "issue" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 1 diff --git a/tests/integration/test_webhooks_milestones.py b/tests/integration/test_webhooks_milestones.py new file mode 100644 index 000000000..1151ee58f --- /dev/null +++ b/tests/integration/test_webhooks_milestones.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest.mock import patch +from unittest.mock import Mock + +from .. import factories as f + +from taiga.projects.history import services + + +pytestmark = pytest.mark.django_db(transaction=True) + + +from taiga.base.utils import json + +def test_webhooks_when_create_milestone(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.MilestoneFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "create" + assert data["type"] == "milestone" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + + +def test_webhooks_when_update_milestone(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.MilestoneFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + obj.name = "test webhook update" + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "milestone" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["data"]["name"] == obj.name + assert data["change"]["comment"] == "test_comment" + assert data["change"]["diff"]["name"]["to"] == data["data"]["name"] + assert data["change"]["diff"]["name"]["from"] != data["data"]["name"] + + +def test_webhooks_when_delete_milestone(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.MilestoneFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, delete=True) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "delete" + assert data["type"] == "milestone" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert "data" in data diff --git a/tests/integration/test_webhooks_signals.py b/tests/integration/test_webhooks_signals.py new file mode 100644 index 000000000..bc485247e --- /dev/null +++ b/tests/integration/test_webhooks_signals.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest.mock import patch +from unittest.mock import Mock + +from .. import factories as f + +from taiga.projects.history import services + +pytestmark = pytest.mark.django_db(transaction=True) + + +def test_new_object_with_one_webhook_signal(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + + objects = [ + f.IssueFactory.create(project=project), + f.TaskFactory.create(project=project), + f.UserStoryFactory.create(project=project), + f.WikiPageFactory.create(project=project) + ] + + response = Mock(status_code=200, headers={}, text="ok") + response.elapsed.total_seconds.return_value = 100 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner, comment="test") + assert session_send_mock.call_count == 1 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner) + assert session_send_mock.call_count == 0 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner, comment="test") + assert session_send_mock.call_count == 1 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) + assert session_send_mock.call_count == 1 + + +def test_new_object_with_two_webhook_signals(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + objects = [ + f.IssueFactory.create(project=project), + f.TaskFactory.create(project=project), + f.UserStoryFactory.create(project=project), + f.WikiPageFactory.create(project=project) + ] + + response = Mock(status_code=200, headers={}, text="ok") + response.elapsed.total_seconds.return_value = 100 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner, comment="test") + assert session_send_mock.call_count == 2 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner, comment="test") + assert session_send_mock.call_count == 2 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner) + assert session_send_mock.call_count == 0 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) + assert session_send_mock.call_count == 2 + + +def test_send_request_one_webhook_signal(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + + objects = [ + f.IssueFactory.create(project=project), + f.TaskFactory.create(project=project), + f.UserStoryFactory.create(project=project), + f.WikiPageFactory.create(project=project) + ] + + response = Mock(status_code=200, headers={}, text="ok") + response.elapsed.total_seconds.return_value = 100 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner, comment="test") + assert session_send_mock.call_count == 1 + + for obj in objects: + with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ + patch("taiga.base.utils.urls.validate_private_url", return_value=True): + services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) + assert session_send_mock.call_count == 1 diff --git a/tests/integration/test_webhooks_tasks.py b/tests/integration/test_webhooks_tasks.py new file mode 100644 index 000000000..7e95a0fde --- /dev/null +++ b/tests/integration/test_webhooks_tasks.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest.mock import patch +from unittest.mock import Mock + +from .. import factories as f + +from taiga.projects.history import services + + +pytestmark = pytest.mark.django_db(transaction=True) + + +from taiga.base.utils import json + +def test_webhooks_when_create_task(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.TaskFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "create" + assert data["type"] == "task" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + + +def test_webhooks_when_update_task(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.TaskFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + obj.subject = "test webhook update" + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "task" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["data"]["subject"] == obj.subject + assert data["change"]["comment"] == "test_comment" + assert data["change"]["diff"]["subject"]["to"] == data["data"]["subject"] + assert data["change"]["diff"]["subject"]["from"] != data["data"]["subject"] + + +def test_webhooks_when_delete_task(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.TaskFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, delete=True) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "delete" + assert data["type"] == "task" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert "data" in data + + +def test_webhooks_when_update_task_attachments(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.TaskFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create attachments + attachment1 = f.TaskAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + attachment2 = f.TaskAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "task" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 2 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Update attachment + attachment1.description = "new attachment description" + attachment1.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "task" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 1 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Delete attachment + attachment2.delete() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "task" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 1 + + +def test_webhooks_when_update_task_custom_attributes(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.TaskFactory.create(project=project) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=obj.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=obj.project) + ct2_id = "{}".format(custom_attr_2.id) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create custom attributes + obj.custom_attributes_values.attributes_values = { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + } + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "task" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 2 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Update custom attributes + obj.custom_attributes_values.attributes_values[ct1_id] = "test_2_updated" + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "task" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 1 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Delete custom attributes + del obj.custom_attributes_values.attributes_values[ct1_id] + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "task" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 1 diff --git a/tests/integration/test_webhooks_userstories.py b/tests/integration/test_webhooks_userstories.py new file mode 100644 index 000000000..9bf1f692f --- /dev/null +++ b/tests/integration/test_webhooks_userstories.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest.mock import patch +from unittest.mock import Mock + +from .. import factories as f + +from taiga.projects.history import services + + +pytestmark = pytest.mark.django_db(transaction=True) + + +from taiga.base.utils import json + +def test_webhooks_when_create_user_story(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.UserStoryFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "create" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + + +def test_webhooks_when_update_user_story(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.UserStoryFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + obj.subject = "test webhook update" + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["data"]["subject"] == obj.subject + assert data["change"]["comment"] == "test_comment" + assert data["change"]["diff"]["subject"]["to"] == data["data"]["subject"] + assert data["change"]["diff"]["subject"]["from"] != data["data"]["subject"] + + +def test_webhooks_when_update_assigned_users_user_story(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.UserStoryFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + user = f.create_user() + obj.assigned_users.add(user) + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner,) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert len(data["data"]["assigned_users"]) == \ + obj.assigned_users.count() + assert data["data"]["assigned_users"] == [user.id] + assert not data["change"]["diff"]["assigned_users"]["from"] + assert data["change"]["diff"]["assigned_users"]["to"] == user.username + + +def test_webhooks_when_delete_user_story(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.UserStoryFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, delete=True) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "delete" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert "data" in data + + +def test_webhooks_when_update_user_story_attachments(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.UserStoryFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create attachments + attachment1 = f.UserStoryAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + attachment2 = f.UserStoryAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 2 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Update attachment + attachment1.description = "new attachment description" + attachment1.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 1 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Delete attachment + attachment2.delete() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 1 + + +def test_webhooks_when_update_user_story_custom_attributes(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.UserStoryFactory.create(project=project) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=obj.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=obj.project) + ct2_id = "{}".format(custom_attr_2.id) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create custom attributes + obj.custom_attributes_values.attributes_values = { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + } + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 2 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Update custom attributes + obj.custom_attributes_values.attributes_values[ct1_id] = "test_2_updated" + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 1 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 0 + + # Delete custom attributes + del obj.custom_attributes_values.attributes_values[ct1_id] + obj.custom_attributes_values.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["custom_attributes"]["new"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["changed"]) == 0 + assert len(data["change"]["diff"]["custom_attributes"]["deleted"]) == 1 + + +def test_webhooks_when_update_user_story_points(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + role1 = f.RoleFactory.create(project=project) + role2 = f.RoleFactory.create(project=project) + + points1 = f.PointsFactory.create(project=project, value=None) + points2 = f.PointsFactory.create(project=project, value=1) + points3 = f.PointsFactory.create(project=project, value=2) + + obj = f.UserStoryFactory.create(project=project) + obj.role_points.all().delete() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Set points + f.RolePointsFactory.create(user_story=obj, role=role1, points=points1) + f.RolePointsFactory.create(user_story=obj, role=role2, points=points2) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "" + assert data["change"]["diff"]["points"][role1.name]["from"] == None + assert data["change"]["diff"]["points"][role1.name]["to"] == points1.name + assert data["change"]["diff"]["points"][role2.name]["from"] == None + assert data["change"]["diff"]["points"][role2.name]["to"] == points2.name + + # Change points + obj.role_points.all().update(points=points3) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "userstory" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "" + assert data["change"]["diff"]["points"][role1.name]["from"] == points1.name + assert data["change"]["diff"]["points"][role1.name]["to"] == points3.name + assert data["change"]["diff"]["points"][role2.name]["from"] == points2.name + assert data["change"]["diff"]["points"][role2.name]["to"] == points3.name diff --git a/tests/integration/test_webhooks_wikipages.py b/tests/integration/test_webhooks_wikipages.py new file mode 100644 index 000000000..c760ccb39 --- /dev/null +++ b/tests/integration/test_webhooks_wikipages.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +from unittest.mock import patch +from unittest.mock import Mock + +from .. import factories as f + +from taiga.projects.history import services + + +pytestmark = pytest.mark.django_db(transaction=True) + + +from taiga.base.utils import json + +def test_webhooks_when_create_wiki_page(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.WikiPageFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "create" + assert data["type"] == "wikipage" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + + +def test_webhooks_when_update_wiki_page(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.WikiPageFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + obj.content = "test webhook update" + obj.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "wikipage" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["data"]["content"] == obj.content + assert data["change"]["comment"] == "test_comment" + assert data["change"]["diff"]["content_html"]["from"] != data["change"]["diff"]["content_html"]["to"] + assert obj.content in data["change"]["diff"]["content_html"]["to"] + + +def test_webhooks_when_delete_wiki_page(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.WikiPageFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, delete=True) + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "delete" + assert data["type"] == "wikipage" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert "data" in data + + +def test_webhooks_when_update_wiki_page_attachments(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + obj = f.WikiPageFactory.create(project=project) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner) + assert send_request_mock.call_count == 2 + + # Create attachments + attachment1 = f.WikiAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + attachment2 = f.WikiAttachmentFactory(project=obj.project, content_object=obj, owner=obj.owner) + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "wikipage" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 2 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Update attachment + attachment1.description = "new attachment description" + attachment1.save() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "wikipage" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 1 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 0 + + # Delete attachment + attachment2.delete() + + with patch('taiga.webhooks.tasks._send_request') as send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test_comment") + assert send_request_mock.call_count == 2 + + (webhook_id, url, key, data) = send_request_mock.call_args[0] + assert data["action"] == "change" + assert data["type"] == "wikipage" + assert data["by"]["id"] == obj.owner.id + assert "date" in data + assert data["data"]["id"] == obj.id + assert data["change"]["comment"] == "test_comment" + assert len(data["change"]["diff"]["attachments"]["new"]) == 0 + assert len(data["change"]["diff"]["attachments"]["changed"]) == 0 + assert len(data["change"]["diff"]["attachments"]["deleted"]) == 1 diff --git a/tests/integration/test_wikilinks.py b/tests/integration/test_wikilinks.py new file mode 100644 index 000000000..f86619d38 --- /dev/null +++ b/tests/integration/test_wikilinks.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.urls import reverse +from django.core import mail + +from taiga.base.utils import json +from taiga.projects.notifications.choices import NotifyLevel + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +def test_create_wiki_link_of_existent_wiki_page_with_permissions(client): + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['view_wiki_pages', 'view_wiki_link', + 'add_wiki_page', 'add_wiki_link']) + + f.MembershipFactory.create(project=project, user=project.owner, role=role) + project.owner.notify_policies.filter(project=project).update(notify_level=NotifyLevel.all) + + user = f.UserFactory.create() + f.MembershipFactory.create(project=project, user=user, role=role) + + wiki_page = f.WikiPageFactory.create(project=project, owner=user, slug="test", content="test content") + + mail.outbox = [] + + url = reverse("wiki-links-list") + + data = { + "title": "test", + "href": "test", + "project": project.pk, + } + + assert project.wiki_pages.all().count() == 1 + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert len(mail.outbox) == 0 + assert project.wiki_pages.all().count() == 1 + + +def test_create_wiki_link_of_inexistent_wiki_page_with_permissions(client): + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['view_wiki_pages', 'view_wiki_link', + 'add_wiki_page', 'add_wiki_link']) + + f.MembershipFactory.create(project=project, user=project.owner, role=role) + project.owner.notify_policies.filter(project=project).update(notify_level=NotifyLevel.all) + + user = f.UserFactory.create() + f.MembershipFactory.create(project=project, user=user, role=role) + + mail.outbox = [] + + url = reverse("wiki-links-list") + + data = { + "title": "test", + "href": "test", + "project": project.pk, + } + + assert project.wiki_pages.all().count() == 0 + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert len(mail.outbox) == 1 + assert project.wiki_pages.all().count() == 1 + + +def test_create_wiki_link_of_inexistent_wiki_page_without_permissions(client): + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=['view_wiki_pages', 'view_wiki_link', + 'add_wiki_link']) + + f.MembershipFactory.create(project=project, user=project.owner, role=role) + project.owner.notify_policies.filter(project=project).update(notify_level=NotifyLevel.all) + + user = f.UserFactory.create() + f.MembershipFactory.create(project=project, user=user, role=role) + + mail.outbox = [] + + url = reverse("wiki-links-list") + + data = { + "title": "test", + "href": "test", + "project": project.pk, + } + + assert project.wiki_pages.all().count() == 0 + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert len(mail.outbox) == 0 + assert project.wiki_pages.all().count() == 0 diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/tests/models.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/tests/unit/auth/__init__.py b/tests/unit/auth/__init__.py new file mode 100644 index 000000000..e38750acc --- /dev/null +++ b/tests/unit/auth/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + diff --git a/tests/unit/auth/test_authentication.py b/tests/unit/auth/test_authentication.py new file mode 100644 index 000000000..bfbe83ecc --- /dev/null +++ b/tests/unit/auth/test_authentication.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest + +from datetime import timedelta +from importlib import reload + +from taiga.auth import authentication +from taiga.auth.exceptions import ( + AuthenticationFailed, InvalidToken, +) +from taiga.auth.models import TokenUser +from taiga.auth.settings import api_settings +from taiga.auth.tokens import AccessToken, CancelToken + +from tests import factories as f + +from .utils import override_api_settings, APIRequestFactory + + +################################### +# JWTAuthentication +################################### + +factory = APIRequestFactory() + +fake_token = b'TokenMcTokenface' +fake_header = b'Bearer ' + fake_token + + +def test_jwt_authentication_get_header(backend = authentication.JWTAuthentication()): + # Should return None if no authorization header + request = factory.get('/test-url/') + assert backend.get_header(request) is None + + # Should pull correct header off request + request = factory.get('/test-url/', HTTP_AUTHORIZATION=fake_header) + assert backend.get_header(request) == fake_header + + # Should work for unicode headers + request = factory.get('/test-url/', HTTP_AUTHORIZATION=fake_header.decode('utf-8')) + assert backend.get_header(request) == fake_header + + # Should work with the x_access_token + with override_api_settings(AUTH_HEADER_NAME='HTTP_X_ACCESS_TOKEN'): + # Should pull correct header off request when using X_ACCESS_TOKEN + request = factory.get('/test-url/', HTTP_X_ACCESS_TOKEN=fake_header) + assert backend.get_header(request) == fake_header + + # Should work for unicode headers when using + request = factory.get('/test-url/', HTTP_X_ACCESS_TOKEN=fake_header.decode('utf-8')) + assert backend.get_header(request) == fake_header + + +def test_jwt_authentication_get_raw_token(backend = authentication.JWTAuthentication()): + # Should return None if header lacks correct type keyword + with override_api_settings(AUTH_HEADER_TYPES='JWT'): + reload(authentication) + assert backend.get_raw_token(fake_header) is None + reload(authentication) + + # Should return None if an empty AUTHORIZATION header is sent + assert backend.get_raw_token(b'') is None + + # Should raise error if header is malformed + with pytest.raises(AuthenticationFailed): + backend.get_raw_token(b'Bearer one two') + + with pytest.raises(AuthenticationFailed): + backend.get_raw_token(b'Bearer') + + # Otherwise, should return unvalidated token in header + assert backend.get_raw_token(fake_header) == fake_token + + # Should return token if header has one of many valid token types + with override_api_settings(AUTH_HEADER_TYPES=('JWT', 'Bearer')): + reload(authentication) + assert backend.get_raw_token(fake_header) == fake_token + + reload(authentication) + + +def test_jwt_authentication_get_validated_token(backend = authentication.JWTAuthentication()): + # Should raise InvalidToken if token not valid + AuthToken = api_settings.AUTH_TOKEN_CLASSES[0] + token = AuthToken() + token.set_exp(lifetime=-timedelta(days=1)) + with pytest.raises(InvalidToken): + backend.get_validated_token(str(token)) + + # Otherwise, should return validated token + token.set_exp() + assert backend.get_validated_token(str(token)).payload == token.payload + + # Should not accept tokens not included in AUTH_TOKEN_CLASSES + cancel_token = CancelToken() + with override_api_settings(AUTH_TOKEN_CLASSES=( + 'taiga.auth.tokens.AccessToken', + )): + with pytest.raises(InvalidToken) as e: + backend.get_validated_token(str(cancel_token)) + + messages = e.value.detail['messages'] + assert len(messages) == 1 + assert { + 'token_class': 'AccessToken', + 'token_type': 'access', + 'message': 'Token has wrong type', + } == messages[0] + + # Should accept tokens included in AUTH_TOKEN_CLASSES + access_token = AccessToken() + cancel_token = CancelToken() + with override_api_settings(AUTH_TOKEN_CLASSES=( + 'taiga.auth.tokens.AccessToken', + 'taiga.auth.tokens.CancelToken', + )): + backend.get_validated_token(str(access_token)) + backend.get_validated_token(str(cancel_token)) + + +@pytest.mark.django_db +def test_jwt_authentication_get_user(backend = authentication.JWTAuthentication()): + payload = {'some_other_id': 'foo'} + + # Should raise error if no recognizable user identification + with pytest.raises(InvalidToken): + backend.get_user(payload) + + payload[api_settings.USER_ID_CLAIM] = 42 + + # Should raise exception if user not found + with pytest.raises(AuthenticationFailed): + backend.get_user(payload) + + u = f.UserFactory(username='markhamill') + u.is_active = False + u.save() + + payload[api_settings.USER_ID_CLAIM] = getattr(u, api_settings.USER_ID_FIELD) + + # Should raise exception if user is inactive + with pytest.raises(AuthenticationFailed): + backend.get_user(payload) + + u.is_active = True + u.save() + + # Otherwise, should return correct user + assert backend.get_user(payload).id == u.id + + +################################### +# JWTAuthentication +################################### + +def test_jwt_token_user_authentication_get_user(backend = authentication.JWTTokenUserAuthentication()): + payload = {'some_other_id': 'foo'} + + # Should raise error if no recognizable user identification + with pytest.raises(InvalidToken): + backend.get_user(payload) + + payload[api_settings.USER_ID_CLAIM] = 42 + + # Otherwise, should return a token user object + user = backend.get_user(payload) + + assert isinstance(user, TokenUser) + assert user.id == 42 + + +def test_jwt_token_user_authentication_custom_tokenuser(backend = authentication.JWTTokenUserAuthentication()): + from django.utils.functional import cached_property + + class BobSaget(TokenUser): + @cached_property + def username(self): + return "bsaget" + + temp = api_settings.TOKEN_USER_CLASS + api_settings.TOKEN_USER_CLASS = BobSaget + + # Should return a token user object + payload = {api_settings.USER_ID_CLAIM: 42} + user = backend.get_user(payload) + + assert isinstance(user, api_settings.TOKEN_USER_CLASS) + assert user.id == 42 + assert user.username == "bsaget" + + # Restore default TokenUser for future tests + api_settings.TOKEN_USER_CLASS = temp diff --git a/tests/unit/auth/test_backends.py b/tests/unit/auth/test_backends.py new file mode 100644 index 000000000..e453c6a27 --- /dev/null +++ b/tests/unit/auth/test_backends.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from datetime import datetime, timedelta +import pytest +from unittest.mock import patch + +import jwt +from jwt import PyJWS, algorithms + +from taiga.auth.backends import TokenBackend +from taiga.auth.exceptions import TokenBackendError +from taiga.auth.utils import ( + aware_utcnow, datetime_to_epoch, make_utc, +) + +SECRET = 'not_secret' + +PRIVATE_KEY = ''' +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA3xMJfyl8TOdrsjDLSIodsArJ/NnQB3ZdfbFC5onxATDfRLLA +CHFo3ye694doBKeSe1NFYbfXPvahl6ODX1a23oQyoRQwlL+M99cLcdCa0gGuJXdb +AaF6Em8E+7uSb3290mI+rZmjqyc7gMtKVWKL4e5i2PerFFBoYkZ7E90KOp2t0ZAD +x2uqF4VTOfYLHG0cPgSw9/ptDStJqJVAOiRRqbv0j0GOFMDYNcN0mDlnpryhQFbQ +iMqn4IJIURZUVBJujFSa45cJPvSmMb6NrzZ1crg5UN6/5Mu2mxQzAi21+vpgGL+E +EuekUd7sRgEAjTHjLKzotLAGo7EGa8sL1vMSFwIDAQABAoIBAQCGGWabF/BONswq +CWUazVR9cG7uXm3NHp2jIr1p40CLC7scDCyeprZ5d+PQS4j/S1Ema++Ih8CQbCjG +BJjD5lf2OhhJdt6hfOkcUBzkJZf8aOAsS6zctRqyHCUtwxuLhFZpM4AkUfjuuZ3u +lcawv5YBkpG/hltE0fV+Jop0bWtpwiKxVsHXVcS0WEPXic0lsOTBCw8m81JXqjir +PCBOnkxgNpHSt69S1xnW3l9fPUWVlduO3EIZ5PZG2BxU081eZW31yIlKsDJhfgm6 +R5Vlr5DynqeojAd6SNliCzNXZP28GOpQBrYIeVQWA1yMANvkvd4apz9GmDrjF/Fd +g8Chah+5AoGBAPc/+zyuDZKVHK7MxwLPlchCm5Zb4eou4ycbwEB+P3gDS7MODGu4 +qvx7cstTZMuMavNRcJsfoiMMrke9JrqGe4rFGiKRFLVBY2Xwr+95pKNC11EWI1lF +5qDAmreDsj2alVJT5yZ9hsAWTsk2i+xj+/XHWYVkr67pRvOPRAmGMB+NAoGBAOb4 +CBHe184Hn6Ie+gSD4OjewyUVmr3JDJ41s8cjb1kBvDJ/wv9Rvo9yz2imMr2F0YGc +ytHraM77v8KOJuJWpvGjEg8I0a/rSttxWQ+J0oYJSIPn+eDpAijNWfOp1aKRNALT +pboCXcnSn+djJFKkNJ2hR7R/vrrM6Jyly1jcVS0zAoGAQpdt4Cr0pt0YS5AFraEh +Mz2VUArRLtSQA3F69yPJjlY85i3LdJvZGYVaJp8AT74y8/OkQ3NipNP+gH3WV3hu +/7IUVukCTcsdrVAE4pe9mucevM0cmie0dOlLAlArCmJ/Axxr7jbyuvuHHrRdPT60 +lr6pQr8afh6AKIsWhQYqIeUCgYA+v9IJcN52hhGzjPDl+yJGggbIc3cn6pA4B2UB +TDo7F0KXAajrjrzT4iBBUS3l2Y5SxVNA9tDxsumlJNOhmGMgsOn+FapKPgWHWuMU +WqBMdAc0dvinRwakKS4wCcsVsJdN0UxsHap3Y3a3+XJr1VrKHIALpM0fmP31WQHG +8Y1eiwKBgF6AYXxo0FzZacAommZrAYoxFZT1u4/rE/uvJ2K9HYRxLOVKZe+89ki3 +D7AOmrxe/CAc/D+nNrtUIv3RFGfadfSBWzyLw36ekW76xPdJgqJsSz5XJ/FgzDW+ +WNC5oOtiPOMCymP75oKOjuZJZ2SPLRmiuO/qvI5uAzBHxRC1BKdt +-----END RSA PRIVATE KEY----- +''' + +PUBLIC_KEY = ''' +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3xMJfyl8TOdrsjDLSIod +sArJ/NnQB3ZdfbFC5onxATDfRLLACHFo3ye694doBKeSe1NFYbfXPvahl6ODX1a2 +3oQyoRQwlL+M99cLcdCa0gGuJXdbAaF6Em8E+7uSb3290mI+rZmjqyc7gMtKVWKL +4e5i2PerFFBoYkZ7E90KOp2t0ZADx2uqF4VTOfYLHG0cPgSw9/ptDStJqJVAOiRR +qbv0j0GOFMDYNcN0mDlnpryhQFbQiMqn4IJIURZUVBJujFSa45cJPvSmMb6NrzZ1 +crg5UN6/5Mu2mxQzAi21+vpgGL+EEuekUd7sRgEAjTHjLKzotLAGo7EGa8sL1vMS +FwIDAQAB +-----END PUBLIC KEY----- +''' + +AUDIENCE = 'openid-client-id' + +ISSUER = 'https://www.myoidcprovider.com' + + +hmac_token_backend = TokenBackend('HS256', SECRET) +rsa_token_backend = TokenBackend('RS256', PRIVATE_KEY, PUBLIC_KEY) +aud_iss_token_backend = TokenBackend('RS256', PRIVATE_KEY, PUBLIC_KEY, AUDIENCE, ISSUER) +payload = {'foo': 'bar'} + + +def test_init(): + # Should reject unknown algorithms + with pytest.raises(TokenBackendError): + TokenBackend('oienarst oieanrsto i', 'not_secret') + + TokenBackend('HS256', 'not_secret') + + +@patch.object(algorithms, 'has_crypto', new=False) +def test_init_fails_for_rs_algorithms_when_crypto_not_installed(): + with pytest.raises(TokenBackendError, match=r'You must have cryptography installed to use RS256.'): + TokenBackend('RS256', 'not_secret') + with pytest.raises(TokenBackendError, match=r'You must have cryptography installed to use RS384.'): + TokenBackend('RS384', 'not_secret') + with pytest.raises(TokenBackendError, match=r'You must have cryptography installed to use RS512.'): + TokenBackend('RS512', 'not_secret') + + +def test_encode_hmac(): + # Should return a JSON web token for the given payload + payload = {'exp': make_utc(datetime(year=2000, month=1, day=1))} + + hmac_token = hmac_token_backend.encode(payload) + + # Token could be one of two depending on header dict ordering + assert hmac_token in ( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMH0.NHpdD2X8ub4SE_MZLBedWa57FCpntGaN_r6f8kNKdUs', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMH0.jvxQgXCSDToR8uKoRJcMT-LmMJJn2-NM76nfSR2FOgs', + ) + + +def test_encode_rsa(): + # Should return a JSON web token for the given payload + payload = {'exp': make_utc(datetime(year=2000, month=1, day=1))} + + rsa_token = rsa_token_backend.encode(payload) + + # Token could be one of two depending on header dict ordering + assert rsa_token in ( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMH0.cuE6ocmdxVHXVrGufzn-_ZbXDV475TPPb5jtSacvJsnR3s3tLIm9yR7MF4vGcCAUGJn2hU_JgSOhs9sxntPPVjdwvC3-sxAwfUQ5AgUCEAF5XC7wTvGhmvufzhAgEG_DNsactCh79P8xRnc0iugtlGNyFp_YK582-ZrlfQEp-7C0L9BNG_JCS2J9DsGR7ojO2xGFkFfzezKRkrVTJMPLwpl0JAiZ0iqbQE-Tex7redCrGI388_mgh52GLsNlSIvW2gQMqCVMYndMuYx32Pd5tuToZmLUQ2PJ9RyAZ4fOMApTzoshItg4lGqtnt9CDYzypHULJZohJIPcxFVZZfHxhw', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMH0.pzHTOaVvKJMMkSqksGh-NdeEvQy8Thre3hBM3smUW5Sohtg77KnHpaUYjq30DyRmYQRmPSjEVprh1Yvic_-OeAXPW8WVsF-r4YdJuxWUpuZbIPwJ9E-cMfTZkDkOl18z1zOdlsLtsP2kXyAlptyy9QQsM7AxoqM6cyXoQ5TI0geWccgoahTy3cBtA6pmjm7H0nfeDGqpqYQBhtaFmRuIWn-_XtdN9C6NVmRCcZwyjH-rP3oEm6wtuKJEN25sVWlZm8YRQ-rj7A7SNqBB5tFK2anM_iv4rmBlIEkmr_f2s_WqMxn2EWUSNeqbqiwabR6CZUyJtKx1cPG0B2PqOTcZsg', + ) + + +def test_encode_aud_iss(): + # Should return a JSON web token for the given payload + original_payload = {'exp': make_utc(datetime(year=2000, month=1, day=1))} + payload = original_payload.copy() + + rsa_token = aud_iss_token_backend.encode(payload) + + # Assert that payload has not been mutated by the encode() function + assert payload == original_payload + + # Token could be one of 12 depending on header dict ordering + assert rsa_token in ( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMCwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCIsImlzcyI6Imh0dHBzOi8vd3d3Lm15b2lkY3Byb3ZpZGVyLmNvbSJ9.kSz7KyUZgpKaeQHYSQlhsE-UFLG2zhBiJ2MFCIvhstA4lSIKj3U1fdP1OhEDg7X66EquRRIZrby6M7RncqCdsjRwKrEIaL74KgC4s5PDXa_HC6dtpi2GhXqaLz8YxfCPaNGZ_9q9rs4Z4O6WpwBLNmMQrTxNno9p0uT93Z2yKj5hGih8a9C_CSf_rKtsHW9AJShWGoKpR6qQFKVNP1GAwQOQ6IeEvZenq_LSEywnrfiWp4Y5UF7xi42wWx7_YPQtM9_Bp5sB-DbrKg_8t0zSc-OHeVDgH0TKqygGEea09W0QkmJcROkaEbxt2LxJg9OuSdXgudVytV8ewpgNtWNE4g', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMCwiaXNzIjoiaHR0cHM6Ly93d3cubXlvaWRjcHJvdmlkZXIuY29tIiwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCJ9.l-sJR5VKGKNrEn5W8sfO4tEpPq4oQ-Fm5ttQyqUkF6FRJHmCfS1TZIUSXieDLHmarnb4hdIGLr5y-EAbykiqYaTn8d25oT2_zIPlCYHt0DxxeuOliGad5l3AXbWee0qPoZL7YCV8FaSdv2EjtMDOEiJBG5yTkaqZlRmSkbfqu1_y2DRErv3X5LpfEHuKoum4jv5YpoCS6wAWDaWJ9cXMPQaGc4gXg4cuSQxb_EjiQ3QYyztHhG37gOu1J-r_rudaiiIk_VZQdYNfCcirp8isS0z2dcNij_0bELp_oOaipsF7uwzc6WfNGR7xP50X1a_K6EBZzVs0eXFxvl9b3C_d8A', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiZXhwIjo5NDY2ODQ4MDAsImlzcyI6Imh0dHBzOi8vd3d3Lm15b2lkY3Byb3ZpZGVyLmNvbSJ9.aTwQEIxSzhI5fN4ffQMzZ6h61Ur-Gzh_gPkgOyyWvMX890Z18tC2RisEjXeL5xDGKe2XiEAVtuJa9CjXB9eJoCxNN1k05C-ph82cco-0m_TbMbs0d1MFnfC9ESr4JKynP_Klxi8bi0iZMazduT15pH4UhRkEGsnp8rOKtlt_8_8UOGBJAzG34161lM4JbZqrZDit1DvQdGxaC0lmMgosKg3NDMECfkPe3pGLJ5F_su5yhQk0xyKNCjYyE2FNlilWoDV2KkGiCWdsFOgRMAJwHZ-cdgPg8Vyh2WthBNblsbRVfDrGtfPX0VuW5B0RhBhvI5Iut34P9kkxKRFo3NaiEg', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiaXNzIjoiaHR0cHM6Ly93d3cubXlvaWRjcHJvdmlkZXIuY29tIiwiZXhwIjo5NDY2ODQ4MDB9.w46s7u28LgsnmK6K_5O15q1SFkKeRgkkplFLi5idq1z7qJjXUi45qpXIyQw3W8a0k1fwa22WB_0XC1MTo22OK3Z0aGNCI2ZCBxvZGOAc1WcCUae44a9LckPHp80q0Hs03NvjsuRVLGXRwDVHrYxuGnFxQSEMbZ650-MQkfVFIXVzMOOAn5Yl4ntjigLcw8iPEqJPnDLdFUSnObZjRzK1M6mJf0-125pqcFsCJaa49rjdbTtnN-VuGnKmv9wV1GwevRQPWjx2vinETURVO9IyZCDtdaLJkvL7Z5IpToK5jrZPc1UWAR0VR8WeWfussFoHzJF86LxVxnqIeXnqOhq5SQ', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3d3dy5teW9pZGNwcm92aWRlci5jb20iLCJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiZXhwIjo5NDY2ODQ4MDB9.Np_SJYye14vz0cvALvfYNqZXvXMD_gY6kIaxA458kbeu6veC_Ds45uWgjJFhTSYFAFWd3TB6M7qZbWgiO0ycION2-B9Yfgaf82WzNtPfgDhu51w1cbLnvuOSRvgX69Q6Z2i1SByewKaSDw25BaMv9Ty4DBdoCbG62qELnNKjDSQvuHlz8cRJv2I6xJBeOYgZV-YN8Zmxsles44a57Vvcj-DjVouHj5m4LperIxb9islNUQBPRTbvw1d_tR8O8ny0mQqbsWL7e2J-wfwdduVf1kVCPePkhHMM6GLhPIrSoTgMzZuRYLBJ61yphuDK98zTknNBM-Jtn5cMyBwP9JBJvA', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3d3dy5teW9pZGNwcm92aWRlci5jb20iLCJleHAiOjk0NjY4NDgwMCwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCJ9.KJcWZtEluPrkRbYj2i_QmdWpZqGZt63Q8nO4QAJ4B4418ZfmgB6A54_vUmWd3Xc18DYgReLoNPlaOuRXtR7rzlMk0-ADjV0bsca5NwTNAV2F-gR9Xsr9iFlcPMNAYf4CAs85gg7deMIxlbGTAaoft__58ah2_vdd8o_nem1PdzsPC198AYtcwnIV206qpeCNR8S_ZTU46OaHwCoySVRx9E7tNG13YSCmGvBaEqgQHKH82qLXo0KivFrjGmGP0xePFG1B8HCZl-LH1euXGGCp6S48q-tmepX5GJwvzaZNBam4pfxQ0GIHa7z11e7sEN-196iDPCK8NzDcXFwHOAnyaA', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMCwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCIsImlzcyI6Imh0dHBzOi8vd3d3Lm15b2lkY3Byb3ZpZGVyLmNvbSJ9.MfhVcFN-9Rd0j11CLtxopzREKdyJH1loSpD4ibjP6Aco4-iM5C99B6gliPgOldtuevhneXV2I7NGhmZFULaYhulcLrAgKe3Gj_TK-sHvwb62e14ArugmK_FAhN7UqbX8hU9wP42LaWXqA7ps4kkJSx-sfgHqMzCKewlAZWwyZBoFgWEgoheKZ7ILkSGf0jzBZlS_1R0jFRSrlYD9rI1S4Px-HllS0t32wRCSbzkp6aVMRs46S0ePrN1mK3spsuQXtYhE2913ZC7p2KwyTGfC2FOOeJdRJknh6kI3Z7pTcsjN2jnQN3o8vPEkN3wl7MbAgAsHcUV45pvyxn4SNBmTMQ', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMCwiaXNzIjoiaHR0cHM6Ly93d3cubXlvaWRjcHJvdmlkZXIuY29tIiwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCJ9.3NjgS14SGFyJ6cix2XJZFPlcyeSu4LSduEMUIH0grJuCljbhalyoib5s4JnBaK4slKrQv1WHlhnKB737hX1FF7_EwQM3toCf--DBjrIuq5cYK3rzcn71JDe_op3CvClwpVyxd2vQZtQfP_tWqN6cNTuWF8ZQ0rJGug6Zb-NeE5h68YK_9tXLZC_i5anyjjAVONOc3Nd-TeIUBaLQKQXOddw0gcTcA7vg3uS0gXTEDq-_ZkF-v9bn1ua4_lgRPbuaYvrBFbXSCEdvNORPfPz4zfL3XU09q0gmnmXC9nxjUFVX4BjkP_YiCCO42sqUKY4y7STTB_IkK_04e2wntonVZA', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3d3dy5teW9pZGNwcm92aWRlci5jb20iLCJleHAiOjk0NjY4NDgwMCwiYXVkIjoib3BlbmlkLWNsaWVudC1pZCJ9.b4pdohov81oqzLyCIp4y7e4VYz7LSez7bH0t1o0Zwzau1uXPYXcasT9lxxNMEEiZwHIovPLyWQ6XvF0bMWTk9vc4PyIpkLqsLBJPsuQ-wYUOD04fECmqUX_JaINqm2pPbohTcOQwl0xsE1UMIKTFBZDL1hEXGEMdW9lrPcXilhbC1ikyMpzsmVh55Q_wL2GqydssnOOcDQTqEkWoKvELJJhBcE-YuQkUp8jEVhF3VZ4jEZkzCErTlyXcfe1qXZHkWtw2QNo9s_SfLuRy_fACOo8XE9pHBoE7rqiSm-FmISgiLO1Jj3Pqq-abjN4SnAbU7PZWcV3fUoO1eYLGORmAcw', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3d3dy5teW9pZGNwcm92aWRlci5jb20iLCJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiZXhwIjo5NDY2ODQ4MDB9.yDGMBeee4hj8yDtEvVtIsS4tnkPjDMQADTkNh74wtb3oYPgQNqMRWKngXiwjvW2FmnsCUue2cFzLgTbpqlDq0QKcBP0i_UwBiXk9m2wLo0WRFtgw2zNHYSsowu26sFoEjKLgpPZzKrPlU4pnxqa8u3yqg8vIcSTlpX8t3uDqNqhUKP6x-w6wb25h67XDmnORiMwhaOZE_Gs9-H6uWnKdguTIlU1Tj4CjUEnZgZN7dJORiDnO_vHiAyL5yvRjhp5YK0Pq-TtCj5kWoJsjQiKc4laIcgofAKoq_b62Psns8MhxzAxwM7i0rbQZXXYB0VKMUho88uHlpbSWCZxu415lWw', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiaXNzIjoiaHR0cHM6Ly93d3cubXlvaWRjcHJvdmlkZXIuY29tIiwiZXhwIjo5NDY2ODQ4MDB9.BHSCjFeXS6B7KFJi1LpQMEd3ib4Bp9FY3WcB-v7dtP3Ay0SxQZz_gxIbi-tYiNCBQIlfKcfq6vELOjE1WJ5zxPDQM8uV0Pjl41hqYBu3qFv4649a-o2Cd-MaSPUSUogPxzTh2Bk4IdM3sG1Zbd_At4DR_DQwWJDuChA8duA5yG2XPkZr0YF1ZJ326O_jEowvCJiZpzOpH9QsLVPbiX49jtWTwqQGhvpKEj3ztTLFo8VHO-p8bhOGEph2F73F6-GB0XqiWk2Dm1yKAunJCMsM4qXooWfaX6gj-WFhPI9kEXNFfGmPal5i1gb17YoeinbdV2kjN42oxast2Iaa3CMldw', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJvcGVuaWQtY2xpZW50LWlkIiwiZXhwIjo5NDY2ODQ4MDAsImlzcyI6Imh0dHBzOi8vd3d3Lm15b2lkY3Byb3ZpZGVyLmNvbSJ9.s6sElpfKL8WHWfbD_Kbwiy_ip4O082V8ElZqwugvDpS-7yQ3FTvQ3WXqtVAJc-fBZe4ZsBnrXUWwZV0Nhoe6iWKjEjTPjonQWbWL_WUJmIC2HVz18AOISnqReV2rcuLSHQ2ckhsyktlE9K1Rfj-Hi6f3HzzzePEgTsL2ZdBH6GrcmJVDFKqLLrkvOHShoPp7rcwuFBr0J_S1oqYac5O0B-u0OVnxBXTwij0ThrTGMgVCp2rn6Hk0NvtF6CE49Eu4XP8Ue-APT8l5_SjqX9GcrjkJp8Gif_oyBheL-zRg_v-cU60X6qY9wVolO8WodVPSnlE02XyYhLVxvfK-w5129A', + ) + + +def test_decode_hmac_with_no_expiry(): + no_exp_token = jwt.encode(payload, SECRET, algorithm='HS256') + + hmac_token_backend.decode(no_exp_token) + + +def test_decode_hmac_with_no_expiry_no_verify(): + no_exp_token = jwt.encode(payload, SECRET, algorithm='HS256') + + assert hmac_token_backend.decode(no_exp_token, verify=False) == payload + + +def test_decode_hmac_with_expiry(): + payload['exp'] = aware_utcnow() - timedelta(seconds=1) + + expired_token = jwt.encode(payload, SECRET, algorithm='HS256') + + with pytest.raises(TokenBackendError): + hmac_token_backend.decode(expired_token) + + +def test_decode_hmac_with_invalid_sig(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + token_1 = jwt.encode(payload, SECRET, algorithm='HS256') + payload['foo'] = 'baz' + token_2 = jwt.encode(payload, SECRET, algorithm='HS256') + + token_2_payload = token_2.rsplit('.', 1)[0] + token_1_sig = token_1.rsplit('.', 1)[-1] + invalid_token = token_2_payload + '.' + token_1_sig + + with pytest.raises(TokenBackendError): + hmac_token_backend.decode(invalid_token) + + +def test_decode_hmac_with_invalid_sig_no_verify(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + token_1 = jwt.encode(payload, SECRET, algorithm='HS256') + payload['foo'] = 'baz' + token_2 = jwt.encode(payload, SECRET, algorithm='HS256') + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + token_2_payload = token_2.rsplit('.', 1)[0] + token_1_sig = token_1.rsplit('.', 1)[-1] + invalid_token = token_2_payload + '.' + token_1_sig + + assert hmac_token_backend.decode(invalid_token, verify=False) == payload + + +def test_decode_hmac_success(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + + token = jwt.encode(payload, SECRET, algorithm='HS256') + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + assert hmac_token_backend.decode(token) == payload + + +def test_decode_rsa_with_no_expiry(): + no_exp_token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + rsa_token_backend.decode(no_exp_token) + + +def test_decode_rsa_with_no_expiry_no_verify(): + no_exp_token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + assert hmac_token_backend.decode(no_exp_token, verify=False) == payload + + +def test_decode_rsa_with_expiry(): + payload['exp'] = aware_utcnow() - timedelta(seconds=1) + + expired_token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + with pytest.raises(TokenBackendError): + rsa_token_backend.decode(expired_token) + + +def test_decode_rsa_with_invalid_sig(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + token_payload = token.rsplit('.', 1)[0] + token_sig = token.rsplit('.', 1)[-1] + invalid_token = token_payload + '.' + token_sig.replace("a", "A") + + with pytest.raises(TokenBackendError): + rsa_token_backend.decode(invalid_token) + + +def test_decode_rsa_with_invalid_sig_no_verify(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + token_payload = token.rsplit('.', 1)[0] + token_sig = token.rsplit('.', 1)[-1] + invalid_token = token_payload + '.' + token_sig.replace("a", "A") + + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + assert hmac_token_backend.decode(invalid_token, verify=False) == payload + + +def test_decode_rsa_success(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + assert rsa_token_backend.decode(token) == payload + + +def test_decode_aud_iss_success(): + payload['exp'] = aware_utcnow() + timedelta(days=1) + payload['foo'] = 'baz' + payload['aud'] = AUDIENCE + payload['iss'] = ISSUER + + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + # Payload copied + payload["exp"] = datetime_to_epoch(payload["exp"]) + + assert aud_iss_token_backend.decode(token) == payload + + +def test_decode_when_algorithm_not_available(): + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + pyjwt_without_rsa = PyJWS() + pyjwt_without_rsa.unregister_algorithm('RS256') + with patch.object(jwt, 'decode', new=pyjwt_without_rsa.decode): + with pytest.raises(TokenBackendError, match=r'Invalid algorithm specified'): + rsa_token_backend.decode(token) + + +def test_decode_when_token_algorithm_does_not_match(): + token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256') + + with pytest.raises(TokenBackendError, match=r'Invalid algorithm specified'): + hmac_token_backend.decode(token) diff --git a/tests/unit/auth/test_token_denylist.py b/tests/unit/auth/test_token_denylist.py new file mode 100644 index 000000000..cf0e283e1 --- /dev/null +++ b/tests/unit/auth/test_token_denylist.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +from django.core.management import call_command +from unittest.mock import patch + +from taiga.auth.exceptions import TokenError +from taiga.auth.settings import api_settings +from taiga.auth.token_denylist.models import ( + DenylistedToken, OutstandingToken, +) +from taiga.auth.tokens import ( + AccessToken, RefreshToken +) +from taiga.auth.utils import aware_utcnow, datetime_from_epoch + +from tests import factories as f + + +pytestmark = pytest.mark.django_db + + +def test_refresh_tokens_are_added_to_outstanding_list(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + token = RefreshToken.for_user(user) + + qs = OutstandingToken.objects.all() + outstanding_token = qs.first() + + assert qs.count() == 1 + assert outstanding_token.user == user + assert outstanding_token.jti == token['jti'] + assert outstanding_token.token == str(token) + assert outstanding_token.created_at == token.current_time + assert outstanding_token.expires_at == datetime_from_epoch(token['exp']) + + +def test_access_tokens_are_not_added_to_outstanding_list(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + AccessToken.for_user(user) + + qs = OutstandingToken.objects.all() + + assert qs.exists() == False + + +def test_token_will_not_validate_if_denylisted(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + token = RefreshToken.for_user(user) + outstanding_token = OutstandingToken.objects.first() + + # Should raise no exception + RefreshToken(str(token)) + + # Add token to denylist + DenylistedToken.objects.create(token=outstanding_token) + + with pytest.raises(TokenError) as e: + # Should raise exception + RefreshToken(str(token)) + assert 'denylisted' in e.exception.args[0] + + +def test_tokens_can_be_manually_denylisted(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + token = RefreshToken.for_user(user) + + # Should raise no exception + RefreshToken(str(token)) + + assert OutstandingToken.objects.count() == 1 + + # Add token to denylist + denylisted_token, created = token.denylist() + + # Should not add token to outstanding list if already present + assert OutstandingToken.objects.count() == 1 + + # Should return denylist record and boolean to indicate creation + assert denylisted_token.token.jti == token['jti'] + assert created == True + + with pytest.raises(TokenError) as e: + # Should raise exception + RefreshToken(str(token)) + assert 'denylisted' in e.exception.args[0] + + # If denylisted token already exists, indicate no creation through + # boolean + denylisted_token, created = token.denylist() + assert denylisted_token.token.jti == token['jti'] + assert created == False + + # Should add token to outstanding list if not already present + new_token = RefreshToken() + denylisted_token, created = new_token.denylist() + assert denylisted_token.token.jti == new_token['jti'] + assert created == True + + assert OutstandingToken.objects.count() == 2 + + +def test_flush_expired_tokens_should_delete_any_expired_tokens(): + user = f.UserFactory( + username='test_user', + password='test_password', + ) + # Make some tokens that won't expire soon + not_expired_1 = RefreshToken.for_user(user) + not_expired_2 = RefreshToken.for_user(user) + not_expired_3 = RefreshToken() + + # Denylist fresh tokens + not_expired_2.denylist() + not_expired_3.denylist() + + # Make tokens with fake exp time that will expire soon + fake_now = aware_utcnow() - api_settings.REFRESH_TOKEN_LIFETIME + + with patch('taiga.auth.tokens.aware_utcnow') as fake_aware_utcnow: + fake_aware_utcnow.return_value = fake_now + expired_1 = RefreshToken.for_user(user) + expired_2 = RefreshToken() + + # Denylist expired tokens + expired_1.denylist() + expired_2.denylist() + + # Make another token that won't expire soon + not_expired_4 = RefreshToken.for_user(user) + + # Should be certain number of outstanding tokens and denylisted + # tokens + assert OutstandingToken.objects.count() == 6 + assert DenylistedToken.objects.count() == 4 + + call_command('flushexpiredtokens') + + # Expired outstanding *and* denylisted tokens should be gone + assert OutstandingToken.objects.count() == 4 + assert DenylistedToken.objects.count() == 2 + + assert ( + [i.jti for i in OutstandingToken.objects.order_by('id')] == + [not_expired_1['jti'], not_expired_2['jti'], not_expired_3['jti'], not_expired_4['jti']] + ) + assert ( + [i.token.jti for i in DenylistedToken.objects.order_by('id')] == + [not_expired_2['jti'], not_expired_3['jti']] + ) diff --git a/tests/unit/auth/test_tokens.py b/tests/unit/auth/test_tokens.py new file mode 100644 index 000000000..f91e78cf1 --- /dev/null +++ b/tests/unit/auth/test_tokens.py @@ -0,0 +1,430 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest + +from datetime import datetime, timedelta +from unittest.mock import patch + +from jose import jwt + +from taiga.base import exceptions as exc +from taiga.auth.exceptions import TokenError +from taiga.auth.settings import api_settings +from taiga.auth.state import token_backend +from taiga.auth.tokens import ( + AccessToken, CancelToken, RefreshToken, Token, UntypedToken, +) +from taiga.auth.utils import ( + aware_utcnow, datetime_to_epoch, make_utc, +) + +from tests import factories as f + +from .utils import override_api_settings + + +########################################################## +## Token +########################################################## + +class MyToken(Token): + token_type = 'test' + lifetime = timedelta(days=1) + + +@pytest.fixture +def token(): + return MyToken() + + +def test_init_no_token_type_or_lifetime(): + class MyTestToken(Token): + pass + + with pytest.raises(TokenError): + MyTestToken() + + MyTestToken.token_type = 'test' + + with pytest.raises(TokenError): + MyTestToken() + + del MyTestToken.token_type + MyTestToken.lifetime = timedelta(days=1) + + with pytest.raises(TokenError): + MyTestToken() + + MyTestToken.token_type = 'test' + MyTestToken() + + +def test_init_no_token_given(): + now = make_utc(datetime(year=2000, month=1, day=1)) + + with patch('taiga.auth.tokens.aware_utcnow') as fake_aware_utcnow: + fake_aware_utcnow.return_value = now + t = MyToken() + + assert t.current_time == now + assert t.token is None + + assert len(t.payload) == 3 + assert t.payload['exp'] == datetime_to_epoch(now + MyToken.lifetime) + assert 'jti' in t.payload + assert t.payload[api_settings.TOKEN_TYPE_CLAIM] == MyToken.token_type + + +def test_init_token_given(): + # Test successful instantiation + original_now = aware_utcnow() + + with patch('taiga.auth.tokens.aware_utcnow') as fake_aware_utcnow: + fake_aware_utcnow.return_value = original_now + good_token = MyToken() + + good_token['some_value'] = 'arst' + encoded_good_token = str(good_token) + + now = aware_utcnow() + + # Create new token from encoded token + with patch('taiga.auth.tokens.aware_utcnow') as fake_aware_utcnow: + fake_aware_utcnow.return_value = now + # Should raise no exception + t = MyToken(encoded_good_token) + + # Should have expected properties + assert t.current_time == now + assert t.token == encoded_good_token + + assert len(t.payload) == 4 + assert t['some_value'] == 'arst' + assert t['exp'] == datetime_to_epoch(original_now + MyToken.lifetime) + assert t[api_settings.TOKEN_TYPE_CLAIM] == MyToken.token_type + assert 'jti' in t.payload + + +def test_init_bad_sig_token_given(): + # Test backend rejects encoded token (expired or bad signature) + payload = {'foo': 'bar'} + payload['exp'] = aware_utcnow() + timedelta(days=1) + token_1 = jwt.encode(payload, api_settings.SIGNING_KEY, algorithm='HS256') + payload['foo'] = 'baz' + token_2 = jwt.encode(payload, api_settings.SIGNING_KEY, algorithm='HS256') + + token_2_payload = token_2.rsplit('.', 1)[0] + token_1_sig = token_1.rsplit('.', 1)[-1] + invalid_token = token_2_payload + '.' + token_1_sig + + with pytest.raises(TokenError): + MyToken(invalid_token) + + +def test_init_bad_sig_token_given_no_verify(): + # Test backend rejects encoded token (expired or bad signature) + payload = {'foo': 'bar'} + payload['exp'] = aware_utcnow() + timedelta(days=1) + token_1 = jwt.encode(payload, api_settings.SIGNING_KEY, algorithm='HS256') + payload['foo'] = 'baz' + token_2 = jwt.encode(payload, api_settings.SIGNING_KEY, algorithm='HS256') + + token_2_payload = token_2.rsplit('.', 1)[0] + token_1_sig = token_1.rsplit('.', 1)[-1] + invalid_token = token_2_payload + '.' + token_1_sig + + t = MyToken(invalid_token, verify=False) + + assert t.payload == payload + + +def test_init_expired_token_given(): + t = MyToken() + t.set_exp(lifetime=-timedelta(seconds=1)) + + with pytest.raises(TokenError): + MyToken(str(t)) + + +def test_init_no_type_token_given(): + t = MyToken() + del t[api_settings.TOKEN_TYPE_CLAIM] + + with pytest.raises(TokenError): + MyToken(str(t)) + + +def test_init_wrong_type_token_given(): + t = MyToken() + t[api_settings.TOKEN_TYPE_CLAIM] = 'wrong_type' + + with pytest.raises(TokenError): + MyToken(str(t)) + + +def test_init_no_jti_token_given(): + t = MyToken() + del t['jti'] + + with pytest.raises(TokenError): + MyToken(str(t)) + + +def test_str(): + token = MyToken() + token.set_exp( + from_time=make_utc(datetime(year=2000, month=1, day=1)), + lifetime=timedelta(seconds=0), + ) + + # Delete all but one claim. We want our lives to be easy and for there + # to only be a couple of possible encodings. We're only testing that a + # payload is successfully encoded here, not that it has specific + # content. + del token[api_settings.TOKEN_TYPE_CLAIM] + del token['jti'] + + # Should encode the given token + encoded_token = str(token) + + # Token could be one of two depending on header dict ordering + assert encoded_token in ( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjk0NjY4NDgwMH0.VKoOnMgmETawjDZwxrQaHG0xHdo6xBodFy6FXJzTVxs', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk0NjY4NDgwMH0.iqxxOHV63sjeqNR1GDxX3LPvMymfVB76sOIDqTbjAgk', + ) + + +def test_repr(token): + assert repr(token) == repr(token.payload) + + +def test_getitem(token): + assert token['exp'], token.payload['exp'] + + +def test_setitem(token): + token['test'] = 1234 + assert token.payload['test'], 1234 + + +def test_delitem(token): + token['test'] = 1234 + assert token.payload['test'], 1234 + + del token['test'] + assert 'test' not in token + + +def test_contains(token): + assert 'exp' in token + + +def test_get(token): + token['test'] = 1234 + + assert 1234 == token.get('test') + assert 1234 == token.get('test', 2345) + + assert token.get('does_not_exist') is None + assert 1234 == token.get('does_not_exist', 1234) + + +def test_set_jti(): + token = MyToken() + old_jti = token['jti'] + + token.set_jti() + + assert 'jti' in token + assert old_jti != token['jti'] + + +def test_set_exp(): + now = make_utc(datetime(year=2000, month=1, day=1)) + + token = MyToken() + token.current_time = now + + # By default, should add 'exp' claim to token using `self.current_time` + # and the TOKEN_LIFETIME setting + token.set_exp() + assert token['exp'] == datetime_to_epoch(now + MyToken.lifetime) + + # Should allow overriding of beginning time, lifetime, and claim name + token.set_exp(claim='refresh_exp', from_time=now, lifetime=timedelta(days=1)) + + assert 'refresh_exp' in token + assert token['refresh_exp'] == datetime_to_epoch(now + timedelta(days=1)) + + +def test_check_exp(): + token = MyToken() + + # Should raise an exception if no claim of given kind + with pytest.raises(TokenError): + token.check_exp('non_existent_claim') + + current_time = token.current_time + lifetime = timedelta(days=1) + exp = token.current_time + lifetime + + token.set_exp(lifetime=lifetime) + + # By default, checks 'exp' claim against `self.current_time`. Should + # raise an exception if claim has expired. + token.current_time = exp + with pytest.raises(TokenError): + token.check_exp() + + token.current_time = exp + timedelta(seconds=1) + with pytest.raises(TokenError): + token.check_exp() + + # Otherwise, should raise no exception + token.current_time = current_time + token.check_exp() + + # Should allow specification of claim to be examined and timestamp to + # compare against + + # Default claim + with pytest.raises(TokenError): + token.check_exp(current_time=exp) + + token.set_exp('refresh_exp', lifetime=timedelta(days=1)) + + # Default timestamp + token.check_exp('refresh_exp') + + # Given claim and timestamp + with pytest.raises(TokenError): + token.check_exp('refresh_exp', current_time=current_time + timedelta(days=1)) + with pytest.raises(TokenError): + token.check_exp('refresh_exp', current_time=current_time + timedelta(days=2)) + + +@pytest.mark.django_db +def test_for_user(): + username = 'test_user' + user = f.UserFactory(username=username) + + token = MyToken.for_user(user) + + user_id = getattr(user, api_settings.USER_ID_FIELD) + if not isinstance(user_id, int): + user_id = str(user_id) + + assert token[api_settings.USER_ID_CLAIM] == user_id + + # Test with non-int user id + with override_api_settings(USER_ID_FIELD='username'): + token = MyToken.for_user(user) + + assert token[api_settings.USER_ID_CLAIM] == username + + +def test_get_token_backend(): + token = MyToken() + + assert token.get_token_backend() == token_backend + + +########################################################## +## AccessToken +########################################################## + +def test_access_token_init(): + # Should set token type claim + token = AccessToken() + assert token[api_settings.TOKEN_TYPE_CLAIM] == 'access' + + +########################################################## +## RefreshToken +########################################################## + +def test_refresh_token_init(): + # Should set token type claim + token = RefreshToken() + assert token[api_settings.TOKEN_TYPE_CLAIM] == 'refresh' + + +def test_refresh_token_access_token(): + # Should create an access token from a refresh token + refresh = RefreshToken() + refresh['test_claim'] = 'arst' + + access = refresh.access_token + + assert isinstance(access, AccessToken) + assert access[api_settings.TOKEN_TYPE_CLAIM] == 'access' + + # Should keep all copyable claims from refresh token + assert refresh['test_claim'] == access['test_claim'] + + # Should not copy certain claims from refresh token + for claim in RefreshToken.no_copy_claims: + assert refresh[claim] != access[claim] + + +########################################################## +## CancelToken +########################################################## + +def test_cancel_token_init(): + # Should set token type claim + token = CancelToken() + assert token[api_settings.TOKEN_TYPE_CLAIM] == 'cancel_account' + + +########################################################## +## UntypedToken +########################################################## + +def test_untyped_token_it_should_accept_and_verify_any_type_of_token(): + access_token = AccessToken() + refresh_token = RefreshToken() + cancel_token = CancelToken() + + for t in (access_token, refresh_token, cancel_token): + untyped_token = UntypedToken(str(t)) + + assert t.payload == untyped_token.payload + + +def test_untyped_token_it_should_expire_immediately_if_made_from_scratch(): + t = UntypedToken() + + assert t[api_settings.TOKEN_TYPE_CLAIM] == 'untyped' + + with pytest.raises(TokenError): + t.check_exp() diff --git a/tests/unit/auth/utils.py b/tests/unit/auth/utils.py new file mode 100644 index 000000000..4966975ef --- /dev/null +++ b/tests/unit/auth/utils.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC +# +# The code is partially taken (and modified) from djangorestframework-simplejwt v. 4.7.1 +# (https://github.com/jazzband/djangorestframework-simplejwt/tree/5997c1aee8ad5182833d6b6759e44ff0a704edb4) +# that is licensed under the following terms: +# +# Copyright 2017 David Sanders +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import contextlib + +from django.conf import settings +from django.test.client import RequestFactory as DjangoRequestFactory +from django.utils.encoding import force_bytes +from django.utils.http import urlencode + +from taiga.auth.settings import api_settings +from taiga.base.api import renderers + + +@contextlib.contextmanager +def override_api_settings(**settings): + old_settings = {} + + for k, v in settings.items(): + # Save settings + try: + old_settings[k] = api_settings.user_settings[k] + except KeyError: + pass + + # Install temporary settings + api_settings.user_settings[k] = v + + # Delete any cached settings + try: + delattr(api_settings, k) + except AttributeError: + pass + + yield + + for k in settings.keys(): + # Delete temporary settings + api_settings.user_settings.pop(k) + + # Restore saved settings + try: + api_settings.user_settings[k] = old_settings[k] + except KeyError: + pass + + # Delete any cached settings + try: + delattr(api_settings, k) + except AttributeError: + pass + + +class APIRequestFactory(DjangoRequestFactory): + renderer_classes_list = [ + renderers.MultiPartRenderer, + renderers.JSONRenderer + ] + default_format = "multipart" + + def __init__(self, enforce_csrf_checks=False, **defaults): + self.enforce_csrf_checks = enforce_csrf_checks + self.renderer_classes = {} + for cls in self.renderer_classes_list: + self.renderer_classes[cls.format] = cls + super().__init__(**defaults) + + def _encode_data(self, data, format=None, content_type=None): + """ + Encode the data returning a two tuple of (bytes, content_type) + """ + + if data is None: + return ('', content_type) + + assert format is None or content_type is None, ( + 'You may not set both `format` and `content_type`.' + ) + + if content_type: + # Content type specified explicitly, treat data as a raw bytestring + ret = force_bytes(data, settings.DEFAULT_CHARSET) + + else: + format = format or self.default_format + + assert format in self.renderer_classes, ( + "Invalid format '{}'. Available formats are {}. " + "Set TEST_REQUEST_RENDERER_CLASSES to enable " + "extra request formats.".format( + format, + ', '.join(["'" + fmt + "'" for fmt in self.renderer_classes]) + ) + ) + + # Use format and render the data into a bytestring + renderer = self.renderer_classes[format]() + ret = renderer.render(data) + + # Determine the content-type header from the renderer + content_type = renderer.media_type + if renderer.charset: + content_type = "{}; charset={}".format( + content_type, renderer.charset + ) + + # Coerce text to bytes if required. + if isinstance(ret, str): + ret = ret.encode(renderer.charset) + + return ret, content_type + + def get(self, path, data=None, **extra): + r = { + 'QUERY_STRING': urlencode(data or {}, doseq=True), + } + if not data and '?' in path: + # Fix to support old behavior where you have the arguments in the + # url. See #1461. + query_string = force_bytes(path.split('?')[1]) + query_string = query_string.decode('iso-8859-1') + r['QUERY_STRING'] = query_string + r.update(extra) + return self.generic('GET', path, **r) + + def post(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('POST', path, data, content_type, **extra) + + def put(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('PUT', path, data, content_type, **extra) + + def patch(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('PATCH', path, data, content_type, **extra) + + def delete(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('DELETE', path, data, content_type, **extra) + + def options(self, path, data=None, format=None, content_type=None, **extra): + data, content_type = self._encode_data(data, format, content_type) + return self.generic('OPTIONS', path, data, content_type, **extra) + + def generic(self, method, path, data='', + content_type='application/octet-stream', secure=False, **extra): + # Include the CONTENT_TYPE, regardless of whether or not data is empty. + if content_type is not None: + extra['CONTENT_TYPE'] = str(content_type) + + return super().generic( + method, path, data, content_type, secure, **extra) + + def request(self, **kwargs): + request = super().request(**kwargs) + request._dont_enforce_csrf_checks = not self.enforce_csrf_checks + return request + diff --git a/tests/unit/test_attachments_services.py b/tests/unit/test_attachments_services.py new file mode 100644 index 000000000..91c7aa0ba --- /dev/null +++ b/tests/unit/test_attachments_services.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from unittest import mock + +import pytest + +from taiga.projects.attachments import services + +from .. import factories as f + + +@pytest.mark.django_db(transaction=True) +def test_get_attachment_by_id(django_assert_num_queries): + att, other_att = f.IssueAttachmentFactory(), f.IssueAttachmentFactory() + assert att.content_object.project_id != other_att.content_object.project_id + + # Attachment not exist + with django_assert_num_queries(1): + assert services.get_attachment_by_id(other_att.content_object.project_id, 99999) is None + + # Attachment does not belong to an object of the project + with django_assert_num_queries(2): + assert services.get_attachment_by_id(other_att.content_object.project_id, att.id) is None + + # Attachment do belongs to the project + with django_assert_num_queries(2): + assert services.get_attachment_by_id(att.content_object.project_id, att.id) == att + + +@pytest.mark.parametrize("url, expected", [ + ("http://media.example.com/a/file.png", "http://media.example.com/a/file.png"), + ("http://media.example.com/a/file.png?token=x", "http://media.example.com/a/file.png?token=x"), + ("/a/file.png", None), + ("http://www.example.com/logo.png", None), +]) +def test_url_is_an_attachment(url, expected): + assert services.url_is_an_attachment(url, base="http://media.example.com/a/") == expected + + +@pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize("attachment_factory, expected", [ + (f.WikiAttachmentFactory, "wikipage"), + (f.IssueAttachmentFactory, "issue"), +]) +def test_generate_refresh_fragment(attachment_factory, expected): + att = attachment_factory() + frag = services.generate_refresh_fragment(att) + assert "{}={}:{}".format(services.REFRESH_PARAM, expected, att.id) == frag + + +@pytest.mark.parametrize("url, expected", [ + ("http://static.test/a/file.png", (False, False)), + ("http://static.test/a/file.png?token=x", (False, False)), + ("http://static.test/a/file.png?token=x#spurious", (False, False)), + ("http://static.test/a/file.png?token=x#" + services.REFRESH_PARAM, (False, False)), + ("http://static.test/a/file.png?token=x#" + services.REFRESH_PARAM + "=xxx", (False, False)), + ("http://static.test/a/file.png?token=x#" + services.REFRESH_PARAM + "=us:42", ("us", 42)), +]) +def test_render_attachment_extract_refresh_id(url, expected): + assert services.extract_refresh_id(url) == expected + + +@pytest.mark.parametrize("attachment, expected", [ + (mock.MagicMock(id=42), services.REFRESH_PARAM + "=us:42"), +]) +def test_generate_refresh_fragment_with_type(attachment, expected): + assert services.generate_refresh_fragment(attachment, "us") == expected diff --git a/tests/unit/test_base_api_permissions.py b/tests/unit/test_base_api_permissions.py new file mode 100644 index 000000000..e8f55c0ae --- /dev/null +++ b/tests/unit/test_base_api_permissions.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.base.api.permissions import (AllowAny as TruePermissionComponent, + DenyAll as FalsePermissionComponent) + + +def test_permission_component_composition(): + assert (TruePermissionComponent() | TruePermissionComponent()).check_permissions(None, None, None) + assert (TruePermissionComponent() | FalsePermissionComponent()).check_permissions(None, None, None) + assert (FalsePermissionComponent() | TruePermissionComponent()).check_permissions(None, None, None) + assert not (FalsePermissionComponent() | FalsePermissionComponent()).check_permissions(None, None, None) + + assert (TruePermissionComponent() & TruePermissionComponent()).check_permissions(None, None, None) + assert not (TruePermissionComponent() & FalsePermissionComponent()).check_permissions(None, None, None) + assert not (FalsePermissionComponent() & TruePermissionComponent()).check_permissions(None, None, None) + assert not (FalsePermissionComponent() & FalsePermissionComponent()).check_permissions(None, None, None) + + assert (~FalsePermissionComponent()).check_permissions(None, None, None) + assert not (~TruePermissionComponent()).check_permissions(None, None, None) diff --git a/tests/unit/test_common_throttle.py b/tests/unit/test_common_throttle.py new file mode 100644 index 000000000..170fd80a2 --- /dev/null +++ b/tests/unit/test_common_throttle.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.test import RequestFactory +from django.core.cache import cache +from django.contrib.auth.models import AnonymousUser + +from taiga.base.throttling import CommonThrottle +from taiga.users.models import User + + +def test_user_no_write_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + request = rf.post("/test") + request.user = User(id=1) + throttling = CommonThrottle() + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + +def test_user_simple_write_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = "1/min" + request = rf.post("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + +def test_user_multi_write_first_small_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = ["1/min", "10/min"] + request = rf.post("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + +def test_user_multi_write_first_big_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = ["10/min", "1/min"] + request = rf.post("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-write'] = None + +def test_user_no_read_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + +def test_user_simple_read_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "1/min" + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + +def test_user_multi_read_first_small_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = ["1/min", "10/min"] + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + +def test_user_multi_read_first_big_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = ["10/min", "1/min"] + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + +def test_whitelisted_user_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [1] + request = rf.get("/test") + request.user = User(id=1) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [] + +def test_not_whitelisted_user_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [1] + request = rf.get("/test") + request.user = User(id=2) + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['user-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [] + +def test_anon_no_write_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + request = rf.post("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + +def test_anon_simple_write_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = "1/min" + request = rf.post("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + +def test_anon_multi_write_first_small_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = ["1/min", "10/min"] + request = rf.post("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + +def test_anon_multi_write_first_big_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = ["10/min", "1/min"] + request = rf.post("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-write'] = None + +def test_anon_no_read_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + request = rf.get("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + +def test_anon_simple_read_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "1/min" + request = rf.get("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + +def test_anon_multi_read_first_small_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = ["1/min", "10/min"] + request = rf.get("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + +def test_anon_multi_read_first_big_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = ["10/min", "1/min"] + request = rf.get("/test") + request.user = AnonymousUser() + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + +def test_whitelisted_anon_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = ["127.0.0.1"] + request = rf.get("/test") + request.user = AnonymousUser() + request.META["REMOTE_ADDR"] = "127.0.0.1" + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [] + +def test_not_whitelisted_anon_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = ["127.0.0.1"] + request = rf.get("/test") + request.user = AnonymousUser() + request.META["REMOTE_ADDR"] = "127.0.0.2" + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [] + +def test_whitelisted_subnet_anon_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = ["192.168.0.0/24"] + request = rf.get("/test") + request.user = AnonymousUser() + request.META["REMOTE_ADDR"] = "192.168.0.123" + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) + for x in range(100): + assert throttling.allow_request(request, None) + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [] + +def test_not_whitelisted_subnet_anon_throttling(settings, rf): + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = "1/min" + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = ["192.168.0.0/24"] + request = rf.get("/test") + request.user = AnonymousUser() + request.META["REMOTE_ADDR"] = "192.168.1.123" + throttling = CommonThrottle() + assert throttling.allow_request(request, None) + assert throttling.allow_request(request, None) is False + for x in range(100): + assert throttling.allow_request(request, None) is False + cache.clear() + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon-read'] = None + settings.REST_FRAMEWORK['DEFAULT_THROTTLE_WHITELIST'] = [] diff --git a/tests/unit/test_due_date_serializers.py b/tests/unit/test_due_date_serializers.py new file mode 100644 index 000000000..56083e482 --- /dev/null +++ b/tests/unit/test_due_date_serializers.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import datetime as dt +from unittest import mock + +import pytest + +from django.utils import timezone + +from taiga.projects.due_dates.serializers import DueDateSerializerMixin + +@pytest.mark.parametrize('due_date, is_closed, expected', [ + (None, False, 'not_set'), + (dt.date(2100, 1, 1), True, 'no_longer_applicable'), + (dt.date(2100, 12, 31), False, 'set'), + (dt.date(2000, 1, 1), False, 'past_due'), + (timezone.now().date(), False, 'due_soon'), +]) +def test_due_date_status(due_date, is_closed, expected): + serializer = DueDateSerializerMixin() + obj_status = mock.MagicMock(is_closed=is_closed) + obj = mock.MagicMock(due_date=due_date, status=obj_status) + status = serializer.get_due_date_status(obj) + assert status == expected diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py new file mode 100644 index 000000000..734b6948a --- /dev/null +++ b/tests/unit/test_export.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import io + +from taiga.base.utils import json +from taiga.export_import.services import render_project + +from tests.utils import disconnect_signals, reconnect_signals + +from .. import factories as f + + +pytestmark = pytest.mark.django_db(transaction=True) + + +def setup_module(): + disconnect_signals() + + +def teardown_module(): + reconnect_signals() + + +def test_export_issue_finish_date(client): + issue = f.IssueFactory.create(finished_date="2014-10-22T00:00:00+0000") + output = io.BytesIO() + render_project(issue.project, output) + project_data = json.loads(output.getvalue()) + finish_date = project_data["issues"][0]["finished_date"] + assert finish_date == "2014-10-22T00:00:00+0000" + + +def test_export_user_story_finish_date(client): + user_story = f.UserStoryFactory.create(finish_date="2014-10-22T00:00:00+0000") + output = io.BytesIO() + render_project(user_story.project, output) + project_data = json.loads(output.getvalue()) + finish_date = project_data["user_stories"][0]["finish_date"] + assert finish_date == "2014-10-22T00:00:00+0000" + + +def test_export_epic_with_user_stories(client): + epic = f.EpicFactory.create(subject="test epic export") + user_story = f.UserStoryFactory.create(project=epic.project) + f.RelatedUserStory.create(epic=epic, user_story=user_story) + output = io.BytesIO() + render_project(user_story.project, output) + project_data = json.loads(output.getvalue()) + assert project_data["epics"][0]["subject"] == "test epic export" + assert len(project_data["epics"]) == 1 + + assert project_data["epics"][0]["related_user_stories"][0]["user_story"] == user_story.ref + assert len(project_data["epics"][0]["related_user_stories"]) == 1 diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py new file mode 100644 index 000000000..4149e5324 --- /dev/null +++ b/tests/unit/test_import.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest +import io +from .. import factories as f + +from taiga.base.utils import json +from taiga.export_import.services import render_project, store_project_from_dict + +pytestmark = pytest.mark.django_db(transaction=True) + + +def test_import_epic_with_user_stories(client): + project = f.ProjectFactory() + project.default_points = f.PointsFactory.create(project=project) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_epic_status = f.EpicStatusFactory.create(project=project) + project.default_us_status = f.UserStoryStatusFactory.create(project=project) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + + epic = f.EpicFactory.create(subject="test epic export", project=project, status=project.default_epic_status) + user_story = f.UserStoryFactory.create(project=project, status=project.default_us_status, milestone=None) + f.RelatedUserStory.create(epic=epic, user_story=user_story, order=55) + output = io.BytesIO() + render_project(user_story.project, output) + project_data = json.loads(output.getvalue()) + + epic.project.delete() + + project = store_project_from_dict(project_data) + assert project.epics.count() == 1 + assert project.epics.first().ref == epic.ref + + assert project.epics.first().user_stories.count() == 1 + related_userstory = project.epics.first().relateduserstory_set.first() + assert related_userstory.user_story.ref == user_story.ref + assert related_userstory.order == 55 + assert related_userstory.epic.ref == epic.ref diff --git a/tests/unit/test_mdrender.py b/tests/unit/test_mdrender.py new file mode 100644 index 000000000..a8684b051 --- /dev/null +++ b/tests/unit/test_mdrender.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from unittest.mock import patch, MagicMock + +from taiga.mdrender.extensions import emojify +from taiga.mdrender.extensions import refresh_attachment +from taiga.mdrender.service import render, cache_by_sha, get_diff_of_htmls, render_and_extract +from taiga.projects.attachments.services import REFRESH_PARAM + +import time + +dummy_project = MagicMock() +dummy_project.id = 1 +dummy_project.slug = "test" + + +dummy_object = MagicMock() +del dummy_object.slug +dummy_object.project = dummy_project + + +def test_proccessor_valid_emoji(): + result = emojify.EmojifyPreprocessor().run(["**:smile:**"]) + assert result == ["**![smile](http://localhost:8000/static/img/emojis/smile.png)**"] + + +def test_proccessor_invalid_emoji(): + result = emojify.EmojifyPreprocessor().run(["**:notvalidemoji:**"]) + assert result == ["**:notvalidemoji:**"] + + +def test_mentions_valid_username(): + with patch("taiga.mdrender.extensions.mentions.get_user_model") as get_user_model_mock: + dummy_uuser = MagicMock() + dummy_uuser.get_full_name.return_value = "Hermione Granger" + get_user_model_mock.return_value.objects.get = MagicMock(return_value=dummy_uuser) + + result = render(dummy_project, "text @hermione text") + + get_user_model_mock.return_value.objects.get.assert_called_with( + memberships__project_id=1, + username="hermione", + ) + assert result == ('

text @hermione text

') + + +def test_mentions_valid_username_with_points(): + with patch("taiga.mdrender.extensions.mentions.get_user_model") as get_user_model_mock: + dummy_uuser = MagicMock() + dummy_uuser.get_full_name.return_value = "Luna Lovegood" + get_user_model_mock.return_value.objects.get = MagicMock(return_value=dummy_uuser) + + result = render(dummy_project, "text @luna.lovegood text") + + get_user_model_mock.return_value.objects.get.assert_called_with( + memberships__project_id=1, + username="luna.lovegood", + ) + assert result == ('

text @luna.lovegood text

') + + +def test_mentions_valid_username_with_dash(): + with patch("taiga.mdrender.extensions.mentions.get_user_model") as get_user_model_mock: + dummy_uuser = MagicMock() + dummy_uuser.get_full_name.return_value = "Ginny Weasley" + get_user_model_mock.return_value.objects.get = MagicMock(return_value=dummy_uuser) + + result = render(dummy_project, "text @super-ginny text") + + get_user_model_mock.return_value.objects.get.assert_called_with( + memberships__project_id=1, + username="super-ginny", + ) + assert result == ('

text @super-ginny text

') + + +def test_proccessor_valid_us_reference(): + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + instance = mock.return_value + instance.content_type.model = "userstory" + instance.content_object.subject = "test" + result = render(dummy_project, "**#1**") + expected_result = '

#1

' + assert result == expected_result + + +def test_proccessor_valid_issue_reference(): + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + instance = mock.return_value + instance.content_type.model = "issue" + instance.content_object.subject = "test" + result = render(dummy_project, "**#2**") + expected_result = '

#2

' + assert result == expected_result + + +def test_proccessor_valid_task_reference(): + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + instance = mock.return_value + instance.content_type.model = "task" + instance.content_object.subject = "test" + result = render(dummy_project, "**#3**") + expected_result = '

#3

' + assert result == expected_result + + +def test_proccessor_invalid_type_reference(): + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + instance = mock.return_value + instance.content_type.model = "other" + instance.content_object.subject = "test" + result = render(dummy_project, "**#4**") + assert result == "

#4

" + + +def test_proccessor_invalid_reference(): + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + mock.return_value = None + result = render(dummy_project, "**#5**") + assert result == "

#5

" + + +def test_render_wiki_strong(): + assert render(dummy_project, "**test**") == "

test

" + assert render(dummy_project, "__test__") == "

test

" + + +def test_render_wiki_italic(): + assert render(dummy_project, "*test*") == "

test

" + assert render(dummy_project, "_test_") == "

test

" + + +def test_render_wiki_strike(): + assert render(dummy_project, "~~test~~") == "

test

" + + +def test_render_wikilink(): + expected_result = "

test

" + assert render(dummy_project, "[[test]]") == expected_result + + +def test_render_wikilink_1(): + expected_result = "

test

" + assert render(dummy_project, "[[test]]") == expected_result + + +def test_render_wikilink_2(): + expected_result = "

test page

" + assert render(dummy_project, "[[test page]]") == expected_result + + +def test_render_wikilink_3(): + expected_result = "

TestPage

" + assert render(dummy_project, "[[TestPage]]") == expected_result + + +def test_render_wikilink_with_custom_title(): + expected_result = "

custom

" + assert render(dummy_project, "[[test|custom]]") == expected_result + + +def test_render_wikilink_slug_to_wikipages(): + expected_result = "

wiki

" + assert render(dummy_project, "[wiki](wiki_page \"wiki page\")") == expected_result + + +def test_render_wikilink_relative_to_absolute(): + expected_result = "

test project

" + assert render(dummy_project, "[test project](/project/test/)") == expected_result + + +def test_render_wikilink_obj_without_slug_absolute(): + expected_result = "

test project

" + assert render(dummy_object, "[test project](/project/test/)") == expected_result + + +def test_render_wikilink_obj_without_slug_relative(): + expected_result = "

test project

" + assert render(dummy_object, "[test project](wiki_page)") == expected_result + + +def test_render_reference_links(): + expected_result = "

An example of reference link

" + source = "An [example][id] of reference link\n [id]: http://example.com/ \"Title\"" + assert render(dummy_project, source) == expected_result + + +def test_render_url_autolinks(): + expected_result = "

Test the http://example.com/ autolink

" + source = "Test the http://example.com/ autolink" + assert render(dummy_project, source) == expected_result + + +def test_render_url_autolinks_without_http(): + expected_result = "

Test the www.example.com autolink

" + source = "Test the www.example.com autolink" + assert render(dummy_project, source) == expected_result + + +def test_render_url_autolinks_with_http(): + expected_result = "

Test the http://example.com/ autolink

" + source = "Test the http://example.com/ autolink" + assert render(dummy_project, source) == expected_result + + +def test_render_url_autolinks_with_https(): + expected_result = "

Test the https://example.com/ autolink

" + source = "Test the https://example.com/ autolink" + assert render(dummy_project, source) == expected_result + + +def test_render_url_autolinks_with_ftp(): + expected_result = "

Test the ftp://example.com/ autolink

" + source = "Test the ftp://example.com/ autolink" + assert render(dummy_project, source) == expected_result + + +def test_render_url_automail(): + expected_result = "

Test the example@example.com automail

" + source = "Test the example@example.com automail" + assert render(dummy_project, source) == expected_result + + +def test_render_url_automail_case_insensitive(): + expected_result = "

Test the eXAMPle+1@ExamplE.Com automail

" + source = "Test the eXAMPle+1@ExamplE.Com automail" + assert render(dummy_project, source) == expected_result + + +def test_render_absolute_image(): + assert render(dummy_project, "![test](/test.png)") == "

\"test\"

" + + +def test_render_relative_image(): + assert render(dummy_project, "![test](test.png)") == "

\"test\"

" + + +def test_render_triple_quote_code(): + expected_result = '
print("test")\n
' + + assert render(dummy_project, "```python\nprint(\"test\")\n```") == expected_result + + +def test_render_triple_quote_and_lang_code(): + expected_result = '
print("test")\n
' + + assert render(dummy_project, "```python\nprint(\"test\")\n```") == expected_result + + +def test_cache_by_sha(): + @cache_by_sha + def test_cache(project, text): + # Dummy function: ensure every invocation returns a different value + return time.time() + + padding = "X" * 40 # Needed as cache is disabled for text under 40 chars + + result_a_1 = test_cache(dummy_project, "A" + padding) + result_b_1 = test_cache(dummy_project, "B") + result_a_2 = test_cache(dummy_project, "A" + padding) + result_b_2 = test_cache(dummy_project, "B") + + assert result_a_1 != result_b_1 # Evidently + assert result_b_1 != result_b_2 # No cached! + assert result_a_1 == result_a_2 # Cached! + + +def test_get_diff_of_htmls_insertions(): + result = get_diff_of_htmls("", "

test

") + assert result == "<p>test</p>" + + +def test_get_diff_of_htmls_deletions(): + result = get_diff_of_htmls("

test

", "") + assert result == "<p>test</p>" + + +def test_get_diff_of_htmls_modifications(): + result = get_diff_of_htmls("

test1

", "

1test

") + assert result == "<p>1test1</p>" + + +def test_render_and_extract_references(): + with patch("taiga.mdrender.extensions.references.get_instance_by_ref") as mock: + instance = mock.return_value + instance.content_type.model = "issue" + instance.content_object.subject = "test" + (_, extracted) = render_and_extract(dummy_project, "**#1**") + assert extracted['references'] == [instance.content_object] + + +def test_render_attachment_image(settings): + settings.MEDIA_URL = "http://media.example.com/" + attachment_url = "{}path/to/test.png#{}=us:42".format(settings.MEDIA_URL, REFRESH_PARAM) + sentinel_url = "http://__sentinel__/" + + md = "![Test]({})".format(attachment_url) + expected_result = "

\"Test\"

".format(sentinel_url, REFRESH_PARAM, "us:42") + + with patch("taiga.mdrender.extensions.refresh_attachment.get_attachment_by_id") as mock: + attachment = mock.return_value + attachment.id = 42 + attachment.attached_file.url = sentinel_url + + result = render(dummy_project, md) + + assert result == expected_result + assert mock.called is True + mock.assert_called_with(dummy_project.id, 42) + + +def test_render_attachment_file(settings): + settings.MEDIA_URL = "http://media.example.com/" + attachment_url = "{}path/to/file.pdf#{}=us:42".format(settings.MEDIA_URL, REFRESH_PARAM) + sentinel_url = "http://__sentinel__/" + + md = "[Test]({})".format(attachment_url) + expected_result = "

Test

".format(sentinel_url, REFRESH_PARAM, "us:42") + + with patch("taiga.mdrender.extensions.refresh_attachment.get_attachment_by_id") as mock: + attachment = mock.return_value + attachment.id = 42 + attachment.attached_file.url = sentinel_url + + result = render(dummy_project, md) + + assert result == expected_result + assert mock.called is True + mock.assert_called_with(dummy_project.id, 42) + + +def test_render_markdown_to_html(): + assert render(dummy_project, "- [x] test") == "
    \n
  • test
  • \n
" diff --git a/tests/unit/test_milestones.py b/tests/unit/test_milestones.py new file mode 100644 index 000000000..344cd51f0 --- /dev/null +++ b/tests/unit/test_milestones.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from .. import factories as f +from taiga.projects.milestones import services + +pytestmark = pytest.mark.django_db(transaction=True) + + +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) diff --git a/tests/unit/test_notifications_squashing.py b/tests/unit/test_notifications_squashing.py new file mode 100644 index 000000000..80f494c4d --- /dev/null +++ b/tests/unit/test_notifications_squashing.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.projects.notifications import squashing + + +def assert_(expected, squashed, *, ordered=True): + """ + Check if expected entries are the same as the squashed. + + Allow to specify if they must maintain the order or conversely they can + appear in any order. + """ + squashed = list(squashed) + assert len(expected) == len(squashed) + if ordered: + assert expected == squashed + else: + # Can't use a set, just check all of the squashed entries + # are in the expected ones. + for entry in squashed: + assert entry in expected + + +def test_squash_omits_comments(): + history_entries = [ + squashing.HistoryEntry(comment='A', values_diff={'status': ['A', 'B']}), + squashing.HistoryEntry(comment='B', values_diff={'status': ['B', 'C']}), + squashing.HistoryEntry(comment='C', values_diff={'status': ['C', 'B']}), + ] + squashed = squashing.squash_history_entries(history_entries) + assert_(history_entries, squashed) + + +def test_squash_allowed_grouped_at_the_end(): + history_entries = [ + squashing.HistoryEntry(comment='A', values_diff={}), + squashing.HistoryEntry(comment='', values_diff={'status': ['A', 'B']}), + squashing.HistoryEntry(comment='', values_diff={'status': ['B', 'C']}), + squashing.HistoryEntry(comment='', values_diff={'status': ['C', 'D']}), + squashing.HistoryEntry(comment='', values_diff={'status': ['D', 'C']}), + squashing.HistoryEntry(comment='Z', values_diff={}), + ] + expected = [ + squashing.HistoryEntry(comment='A', values_diff={}), + squashing.HistoryEntry(comment='Z', values_diff={}), + squashing.HistoryEntry(comment='', values_diff={'status': ['A', 'C']}), + ] + + squashed = squashing.squash_history_entries(history_entries) + assert_(expected, squashed) + + +def test_squash_remove_noop_changes(): + history_entries = [ + squashing.HistoryEntry(comment='', values_diff={'status': ['A', 'B']}), + squashing.HistoryEntry(comment='', values_diff={'status': ['B', 'A']}), + ] + expected = [] + + squashed = squashing.squash_history_entries(history_entries) + assert_(expected, squashed) + + +def test_squash_remove_noop_changes_but_maintain_others(): + history_entries = [ + squashing.HistoryEntry(comment='', values_diff={'status': ['A', 'B'], 'type': ['1', '2']}), + squashing.HistoryEntry(comment='', values_diff={'status': ['B', 'A']}), + ] + expected = [ + squashing.HistoryEntry(comment='', values_diff={'type': ['1', '2']}), + ] + + squashed = squashing.squash_history_entries(history_entries) + assert_(expected, squashed) + + +def test_squash_values_diff_with_multiple_fields(): + history_entries = [ + squashing.HistoryEntry(comment='', values_diff={'status': ['A', 'B'], 'type': ['1', '2']}), + squashing.HistoryEntry(comment='', values_diff={'status': ['B', 'C']}), + ] + expected = [ + squashing.HistoryEntry(comment='', values_diff={'type': ['1', '2']}), + squashing.HistoryEntry(comment='', values_diff={'status': ['A', 'C']}), + ] + + squashed = squashing.squash_history_entries(history_entries) + assert_(expected, squashed, ordered=False) + + +def test_squash_arrays(): + history_entries = [ + squashing.HistoryEntry(comment='', values_diff={'tags': [['A', 'B'], ['A']]}), + squashing.HistoryEntry(comment='', values_diff={'tags': [['A'], ['A', 'C']]}), + ] + expected = [ + squashing.HistoryEntry(comment='', values_diff={'tags': [['A', 'B'], ['A', 'C']]}), + ] + + squashed = squashing.squash_history_entries(history_entries) + assert_(expected, squashed, ordered=False) diff --git a/tests/unit/test_order_updates.py b/tests/unit/test_order_updates.py new file mode 100644 index 000000000..2f93d335b --- /dev/null +++ b/tests/unit/test_order_updates.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from taiga.projects.services import apply_order_updates + + +def test_apply_order_updates_one_element_backward(): + orders = { + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "f": 6 + } + new_orders = { + "d": 2 + } + apply_order_updates(orders, new_orders) + assert orders == { + "d": 2, + "b": 3, + "c": 4 + } + + +def test_apply_order_updates_one_element_forward(): + orders = { + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "f": 6 + } + new_orders = { + "a": 3 + } + apply_order_updates(orders, new_orders) + assert orders == { + "a": 3, + "c": 4, + "d": 5, + "e": 6, + "f": 7 + } + + +def test_apply_order_updates_multiple_elements_backward(): + orders = { + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "f": 6 + } + new_orders = { + "d": 2, + "e": 3 + } + apply_order_updates(orders, new_orders) + assert orders == { + "d": 2, + "e": 3, + "b": 4, + "c": 5 + } + +def test_apply_order_updates_multiple_elements_forward(): + orders = { + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "f": 6 + } + new_orders = { + "a": 4, + "b": 5 + } + apply_order_updates(orders, new_orders) + assert orders == { + "a": 4, + "b": 5, + "d": 6, + "e": 7, + "f": 8 + } + +def test_apply_order_updates_two_elements(): + orders = { + "a": 0, + "b": 1, + } + new_orders = { + "b": 0 + } + apply_order_updates(orders, new_orders) + assert orders == { + "b": 0, + "a": 1 + } + +def test_apply_order_updates_duplicated_orders(): + orders = { + "a": 1, + "b": 2, + "c": 3, + "d": 3, + "e": 3, + "f": 4 + } + new_orders = { + "a": 3 + } + apply_order_updates(orders, new_orders) + assert orders == { + "a": 3, + "c": 4, + "d": 4, + "e": 4, + "f": 5 + } + +def test_apply_order_updates_multiple_elements_duplicated_orders(): + orders = { + "a": 1, + "b": 2, + "c": 3, + "d": 3, + "e": 3, + "f": 4 + } + new_orders = { + "c": 3, + "d": 3, + "a": 4 + } + apply_order_updates(orders, new_orders) + assert orders == { + "c": 3, + "d": 3, + "a": 4, + "e": 5, + "f": 6 + } + + +def test_apply_order_invalid_new_order(): + orders = { + "a": 1, + "b": 2, + "c": 3, + "d": 3, + "e": 3, + "f": 4 + } + new_orders = { + "c": 3, + "d": 3, + "x": 3, + "a": 4 + } + apply_order_updates(orders, new_orders) + assert orders == { + "c": 3, + "d": 3, + "a": 4, + "e": 5, + "f": 6 + } + + +def test_apply_order_not_include_noop(): + orders = { + "a": 1, + } + new_orders = { + "a": 1, + } + apply_order_updates(orders, new_orders, remove_equal_original=True) + assert orders == {} + + +def test_apply_order_put_it_first(): + orders = { + "a": 0, + "b": 1, + "c": 2, + "z": 99, + } + new_orders = { + "z": 0, + } + apply_order_updates(orders, new_orders, remove_equal_original=True) + assert orders == { + "z": 0, + "a": 1, + "b": 2, + "c": 3, + } + + +def test_apply_order_put_it_first_with_tie(): + orders = { + "a": 0, + "b": 0, + "c": 0, + "d": 1, + "z": 99, + } + new_orders = { + "z": 0, + } + apply_order_updates(orders, new_orders, remove_equal_original=True) + assert orders == { + "z": 0, + "a": 1, + "b": 1, + "c": 1, + "d": 2, + } + + +def test_apply_order_refresh(): + orders = { + "a": 0, + "b": 0, + "c": 0, + "d": 1, + "w": 99, + "z": 0, + } + new_orders = { + "z": 0, + } + apply_order_updates(orders, new_orders, remove_equal_original=True) + assert orders == { + "a": 1, + "b": 1, + "c": 1, + "d": 2, + "w": 100, + } + + +def test_apply_order_maintain_new_values(): + orders = { + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 7, + "f": 6, + "g": 7, + } + new_orders = { + "e": 7, + "g": 8, + } + expected = {"g": 8} + apply_order_updates(orders, new_orders, remove_equal_original=True) + assert expected == orders diff --git a/tests/unit/test_serializer_mixins.py b/tests/unit/test_serializer_mixins.py new file mode 100644 index 000000000..8e2d6ba12 --- /dev/null +++ b/tests/unit/test_serializer_mixins.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.db import models +from taiga.base.api.validators import ModelValidator +from taiga.projects.validators import DuplicatedNameInProjectValidator + +pytestmark = pytest.mark.django_db(transaction=True) + + +class AuxProjectModel(models.Model): + pass + + +class AuxModelWithNameAttribute(models.Model): + name = models.CharField(max_length=255, null=False, blank=False) + project = models.ForeignKey(AuxProjectModel, null=False, blank=False, on_delete=models.CASCADE) + + +class AuxValidator(DuplicatedNameInProjectValidator, ModelValidator): + class Meta: + model = AuxModelWithNameAttribute + + +def test_duplicated_name_validation(): + project = AuxProjectModel.objects.create() + AuxModelWithNameAttribute.objects.create(name="1", project=project) + instance_2 = AuxModelWithNameAttribute.objects.create(name="2", project=project) + + # No duplicated_name + validator = AuxValidator(data={"name": "3", "project": project.id}) + + assert validator.is_valid() + + # Create duplicated_name + validator = AuxValidator(data={"name": "1", "project": project.id}) + + assert not validator.is_valid() + + # Update name to existing one + validator = AuxValidator(data={"id": instance_2.id, "name": "1", "project": project.id}) + + assert not validator.is_valid() diff --git a/tests/unit/test_slug.py b/tests/unit/test_slug.py new file mode 100644 index 000000000..a8ff40453 --- /dev/null +++ b/tests/unit/test_slug.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from django.contrib.auth import get_user_model + +from taiga.projects.models import Project +from taiga.base.utils.slug import slugify + +from tests.utils import disconnect_signals, reconnect_signals + + +def setup_module(): + disconnect_signals() + + +def teardown_module(): + reconnect_signals() + + +def test_slugify_1(): + assert slugify("漢字") == "han-zi" + + +def test_slugify_2(): + assert slugify("TestExamplePage") == "testexamplepage" + + +def test_slugify_3(): + assert slugify(None) == "" + + +@pytest.mark.django_db +def test_project_slug_with_special_chars(): + user = get_user_model().objects.create(username="test") + project = Project.objects.create(name="漢字", description="漢字", owner=user) + project.save() + + assert project.slug == "test-han-zi" + + +@pytest.mark.django_db +def test_project_with_existing_name_slug_with_special_chars(): + user = get_user_model().objects.create(username="test") + Project.objects.create(name="漢字", description="漢字", owner=user) + project = Project.objects.create(name="漢字", description="漢字", owner=user) + + assert project.slug == "test-han-zi-1" diff --git a/tests/unit/test_timeline.py b/tests/unit/test_timeline.py new file mode 100644 index 000000000..6e076ece5 --- /dev/null +++ b/tests/unit/test_timeline.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from unittest.mock import patch, call + +from django.contrib.auth import get_user_model + +from taiga.timeline import service +from taiga.timeline.models import Timeline +from taiga.projects.models import Project + +import pytest + +pytestmark = pytest.mark.django_db(transaction=True) + +def test_push_to_timeline_many_objects(): + with patch("taiga.timeline.service._add_to_object_timeline") as mock: + users = [get_user_model(), get_user_model(), get_user_model()] + owner = get_user_model() + project = Project() + service._push_to_timeline(users, project, "test", project.created_date) + assert mock.call_count == 3 + assert mock.mock_calls == [ + call(users[0], project, "test", project.created_date, "default", {}), + call(users[1], project, "test", project.created_date, "default", {}), + call(users[2], project, "test", project.created_date, "default", {}), + ] + with pytest.raises(Exception): + service._push_to_timeline(None, project, "test") + + +def test_add_to_objects_timeline(): + with patch("taiga.timeline.service._add_to_object_timeline") as mock: + users = [get_user_model(), get_user_model(), get_user_model()] + project = Project() + service._add_to_objects_timeline(users, project, "test", project.created_date) + assert mock.call_count == 3 + assert mock.mock_calls == [ + call(users[0], project, "test", project.created_date, "default", {}), + call(users[1], project, "test", project.created_date, "default", {}), + call(users[2], project, "test", project.created_date, "default", {}), + ] + with pytest.raises(Exception): + service._push_to_timeline(None, project, "test") + + +def test_get_impl_key_from_model(): + assert service._get_impl_key_from_model(Timeline, "test") == "timeline.timeline.test" + with pytest.raises(Exception): + service._get_impl_key(None) + + +def test_get_impl_key_from_typename(): + assert service._get_impl_key_from_typename("timeline.timeline", "test") == "timeline.timeline.test" + with pytest.raises(Exception): + service._get_impl_key(None) + + +def test_register_timeline_implementation(): + test_func = lambda x: "test-func-result" + service.register_timeline_implementation("timeline.timeline", "test", test_func) + assert service._timeline_impl_map["timeline.timeline.test"](None) == "test-func-result" + + @service.register_timeline_implementation("timeline.timeline", "test-decorator") + def decorated_test_function(x): + return "test-decorated-func-result" + + assert service._timeline_impl_map["timeline.timeline.test-decorator"](None) == "test-decorated-func-result" diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 000000000..cca73efa9 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +import pytest + +from unittest import mock + +import django_sites as sites +import re + +from taiga.base.utils.urls import get_absolute_url, is_absolute_url, build_url, \ + validate_private_url, IpAddresValueError, HostnameException +from taiga.base.utils.db import save_in_bulk, update_in_bulk, to_tsquery + +pytestmark = pytest.mark.django_db(transaction=True) + + +def test_is_absolute_url(): + assert is_absolute_url("http://domain/path") + assert is_absolute_url("https://domain/path") + assert not is_absolute_url("://domain/path") + + +def test_get_absolute_url(): + site = sites.get_current() + assert get_absolute_url("http://domain/path") == "http://domain/path" + assert get_absolute_url("/path") == build_url("/path", domain=site.domain, scheme=site.scheme) + + +def test_save_in_bulk(): + instance = mock.Mock() + instances = [instance, instance] + + save_in_bulk(instances) + + assert instance.save.call_count == 2 + + +def test_save_in_bulk_with_a_callback(): + instance = mock.Mock() + callback = mock.Mock() + instances = [instance, instance] + + save_in_bulk(instances, callback) + + assert callback.call_count == 2 + + +def test_update_in_bulk(): + instance = mock.Mock() + instances = [instance, instance] + new_values = [{"field1": 1}, {"field2": 2}] + + update_in_bulk(instances, new_values) + + assert instance.save.call_count == 2 + assert instance.field1 == 1 + assert instance.field2 == 2 + + +def test_update_in_bulk_with_a_callback(): + instance = mock.Mock() + callback = mock.Mock() + instances = [instance, instance] + new_values = [{"field1": 1}, {"field2": 2}] + + update_in_bulk(instances, new_values, callback) + + assert callback.call_count == 2 + + +TS_QUERY_TRANSFORMATIONS = [ + ("1 OR 2", "1 | 2"), + ("(1) 2", "( 1 ) & 2"), + ("&", "'&':*"), + ('"hello world"', "'hello world'"), + ("not 1", "! 1"), + ("1 and not (2 or 3)", "1 & ! ( 2 | 3 )"), + ("not and and 1) or ( 2 not", "! 1 | ( 2 )"), + ("() 1", "1"), + ("1 2 3", "1 & 2 & 3"), + ("'&' |", "'&':* & '|':*"), + (") and 1 (2 or", "1 & ( 2 )"), + ("it's '", "'its':*"), + ("(1)", "( 1 )"), + ("1((", "1"), + ("test\\", "'test':*"), + ('"', "'\"':*"), + ('""', "'\"\"':*"), + ('"""', "'\"\"':* & '\"':*"), +] + + +def test_to_tsquery(): + for (input, expected) in TS_QUERY_TRANSFORMATIONS: + expected = re.sub("([0-9])", r"'\1':*", expected) + actual = to_tsquery(input) + assert actual == expected + + +@pytest.mark.parametrize("url", [ + "http://127.0.0.1", + "http://[::1]", + "http://192.168.0.12", + "http://10.0.0.1", + "https://172.25.0.1", + "https://10.25.23.100", + "ftp://192.168.1.100/", + "http://[::ffff:c0a8:164]/", + "scp://192.168.1.100/", + "http://www.192-168-1-100.sslip.io/", +]) +def test_validate_bad_destination_address(url): + with pytest.raises(IpAddresValueError): + validate_private_url(url) + + +@pytest.mark.parametrize("url", [ + "http://test.local/", + "http://test.test/", +]) +def test_validate_invalid_destination_address(url): + with pytest.raises(HostnameException): + validate_private_url(url) + + +@pytest.mark.parametrize("url", [ + "http://192.167.0.12", + "http://11.0.0.1", + "https://173.25.0.1", + "https://193.24.23.100", + "ftp://173.168.1.100/", + "scp://194.168.1.100/", + "http://www.google.com/", + "http://1.1.1.1/", +]) +def test_validate_good_destination_address(url): + assert validate_private_url(url) is None diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..9081a0de7 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2021-present Kaleidos INC + +from django.db.models import signals + +DUMMY_BMP_DATA = b'BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00\x13\x0b\x00\x00\x13\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + + +def signals_switch(): + pre_save = signals.pre_save.receivers + post_save = signals.post_save.receivers + + def disconnect(): + signals.pre_save.receivers = [] + signals.post_save.receivers = [] + + def reconnect(): + signals.pre_save.receivers = pre_save + signals.post_save.receivers = post_save + + return disconnect, reconnect + + +disconnect_signals, reconnect_signals = signals_switch() + + +def _helper_test_http_method_responses(client, method, url, data, users, after_each_request=None, + content_type="application/json"): + results = [] + + for user in users: + if user is None: + client.logout() + else: + client.login(user) + if data: + response = getattr(client, method)(url, data, content_type=content_type) + else: + response = getattr(client, method)(url) + #if response.status_code >= 400: + # print("Response content:", response.content) + + results.append(response) + + if after_each_request is not None: + after_each_request() + return results + + +def helper_test_http_method(client, method, url, data, users, after_each_request=None, + content_type="application/json"): + responses = _helper_test_http_method_responses(client, method, url, data, users, after_each_request, + content_type=content_type) + return list(map(lambda r: r.status_code, responses)) + + +def helper_test_http_method_and_count(client, method, url, data, users, after_each_request=None): + responses = _helper_test_http_method_responses(client, method, url, data, users, after_each_request) + return list(map(lambda r: (r.status_code, len(r.data) if isinstance(r.data, list) and 200 <= r.status_code < 300 else 0), responses)) + + +def helper_test_http_method_and_keys(client, method, url, data, users, after_each_request=None): + responses = _helper_test_http_method_responses(client, method, url, data, users, after_each_request) + return list(map(lambda r: (r.status_code, set(r.data.keys() if isinstance(r.data, dict) and 200 <= r.status_code < 300 else [])), responses)) diff --git a/thankyou.md b/thankyou.md new file mode 100644 index 000000000..ffd90d5a7 --- /dev/null +++ b/thankyou.md @@ -0,0 +1,40 @@ +# Thank you + +Taiga would like to acknowledge selected individuals and organisations who +contributed to the product development in some way. + +## Security contributions + +We would like to thank everyone listed here for their help, whether small or +big, to improve Taiga's overall security rating. + + +### 2019 + +- Joshua Osabel + +### 2020 + +- Faizan Ahmed + +- Anton Perekopni + +- Ather Iqbal ( Alpha Inferno Smc Pvt Ltd ) + +- Qazi Abdullah + +### 2021 + +- Petar Kosic ( Sec-Research GmbH ) + +- Kunal Mhaske + +### 2022 + +- Husnain Iqbal (CEO of Alpha Inferno PVT LTD) + +### 2023 + +- Khurram Shoaib + +- Kamran Khan diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..b422f1fac --- /dev/null +++ b/tox.ini @@ -0,0 +1,18 @@ +[tox] +envlist = py36 +skipsdist = True + +[testenv] +deps = + -rrequirements.txt + -rrequirements-devel.txt +commands = + pytest + +[testenv:flake8] +deps = flake8 +basepython = python3.6 +commands = flake8 . + +[coverage:run] +relative_files = True